jusi-sql 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_sql/__init__.py ADDED
@@ -0,0 +1,63 @@
1
+ """Provider-neutral SQL family primitives for Jusi 1.0.
2
+
3
+ This module intentionally imports neither IPython nor a terminal application.
4
+ Catalog discovery can therefore import it without crossing runtime boundaries.
5
+ """
6
+
7
+ from .catalog import (
8
+ FAMILY_ID,
9
+ MAGIC_NAME,
10
+ SQL_CAPABILITIES,
11
+ SQL_PRESENTATION,
12
+ sql_catalog_entry,
13
+ sql_family_claim,
14
+ )
15
+ from .completion import (
16
+ CompletionColumn,
17
+ CompletionItem,
18
+ CompletionObject,
19
+ MetadataSnapshot,
20
+ SqlCompletionRequest,
21
+ complete_sql,
22
+ parse_query_relations,
23
+ )
24
+ from .config import (
25
+ ResolvedSqlTarget,
26
+ SqlConfigError,
27
+ SqlFamilyConfig,
28
+ SqlProviderIdentity,
29
+ SqlTarget,
30
+ resolve_sql_target,
31
+ )
32
+ from .metadata import MetadataCache, sql_cache_directory
33
+ from .visidata import SqlSheetActions, bind_sql_actions, find_sql_actions, install_visidata_commands
34
+
35
+ __all__ = [
36
+ "FAMILY_ID",
37
+ "MAGIC_NAME",
38
+ "SQL_CAPABILITIES",
39
+ "SQL_PRESENTATION",
40
+ "CompletionColumn",
41
+ "CompletionItem",
42
+ "CompletionObject",
43
+ "MetadataSnapshot",
44
+ "MetadataCache",
45
+ "ResolvedSqlTarget",
46
+ "SqlCompletionRequest",
47
+ "SqlConfigError",
48
+ "SqlFamilyConfig",
49
+ "SqlProviderIdentity",
50
+ "SqlSheetActions",
51
+ "SqlTarget",
52
+ "complete_sql",
53
+ "bind_sql_actions",
54
+ "find_sql_actions",
55
+ "install_visidata_commands",
56
+ "parse_query_relations",
57
+ "resolve_sql_target",
58
+ "sql_catalog_entry",
59
+ "sql_cache_directory",
60
+ "sql_family_claim",
61
+ ]
62
+
63
+ __version__ = "0.2.0"
jusi_sql/catalog.py ADDED
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Iterable, Mapping
4
+
5
+
6
+ FAMILY_ID = "sql"
7
+ MAGIC_NAME = "sql"
8
+ SQL_CAPABILITIES = ("execute", "followup", "complete", "interrupt", "editor_actions")
9
+ SQL_PRESENTATION = {"syntax": "sql", "indent": "sql"}
10
+
11
+
12
+ def sql_family_claim(
13
+ *,
14
+ provider_presentation: Mapping[str, str] | None = None,
15
+ ) -> dict[str, Any]:
16
+ """Return the one canonical catalog claim shared by every SQL provider."""
17
+ claim: dict[str, Any] = {
18
+ "family_id": FAMILY_ID,
19
+ "magic_name": MAGIC_NAME,
20
+ "capabilities": list(SQL_CAPABILITIES),
21
+ "presentation": dict(SQL_PRESENTATION),
22
+ }
23
+ if provider_presentation:
24
+ claim["provider_presentation"] = dict(provider_presentation)
25
+ return claim
26
+
27
+
28
+ def sql_catalog_entry(
29
+ *,
30
+ plugin_id: str,
31
+ plugin_version: str,
32
+ distribution: str,
33
+ kernel_extension: str,
34
+ worker_entry_point: str,
35
+ provider_presentation: Mapping[str, str] | None = None,
36
+ media_types: Iterable[str] = ("text/x-ansi",),
37
+ ) -> dict[str, Any]:
38
+ """Build an exact-provider catalog entry without importing runtime code."""
39
+ return {
40
+ "plugin_id": _required(plugin_id, "plugin_id"),
41
+ "plugin_version": _required(plugin_version, "plugin_version"),
42
+ "distribution": _required(distribution, "distribution"),
43
+ "families": [sql_family_claim(provider_presentation=provider_presentation)],
44
+ "kernel_extensions": [_required(kernel_extension, "kernel_extension")],
45
+ "worker_entry_point": _required(worker_entry_point, "worker_entry_point"),
46
+ "media_types": list(dict.fromkeys(media_types)),
47
+ "interaction": "terminal_interactive",
48
+ }
49
+
50
+
51
+ def _required(value: str, name: str) -> str:
52
+ normalized = str(value).strip()
53
+ if not normalized:
54
+ raise ValueError(f"{name} must be a non-empty string")
55
+ return normalized
jusi_sql/completion.py ADDED
@@ -0,0 +1,303 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import asdict, dataclass, field
5
+ from typing import Any, Iterable, Mapping, Sequence
6
+
7
+ import sqlparse
8
+ from sqlparse.sql import Identifier, IdentifierList, TokenList
9
+ from sqlparse.tokens import Keyword, Name
10
+
11
+
12
+ RELATION_KEYWORDS = frozenset({"FROM", "JOIN", "INTO", "UPDATE", "TABLE", "VIEW", "DESCRIBE", "DESC"})
13
+ _IDENTIFIER_AT_CURSOR = re.compile(
14
+ r'(?:(?:"[^"]*"|`[^`]*`|[A-Za-z_][A-Za-z0-9_$]*)(?:\.(?:"[^"]*"|`[^`]*`|[A-Za-z_][A-Za-z0-9_$]*))*\.?)$'
15
+ )
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class CompletionObject:
20
+ name: str
21
+ schema: str = ""
22
+ kind: str = "table"
23
+ detail: str = ""
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class CompletionColumn:
28
+ table: str
29
+ name: str
30
+ schema: str = ""
31
+ data_type: str = ""
32
+
33
+
34
+ @dataclass
35
+ class MetadataSnapshot:
36
+ schemas: list[str] = field(default_factory=list)
37
+ objects: list[CompletionObject] = field(default_factory=list)
38
+ columns: list[CompletionColumn] = field(default_factory=list)
39
+ functions: list[CompletionObject] = field(default_factory=list)
40
+ refreshed_at: float = 0.0
41
+
42
+ @classmethod
43
+ def from_dict(cls, payload: Mapping[str, Any]) -> "MetadataSnapshot":
44
+ return cls(
45
+ schemas=[str(item) for item in payload.get("schemas", ()) if str(item)],
46
+ objects=_objects(payload.get("objects", ()), default_kind="table"),
47
+ columns=_columns(payload.get("columns", ())),
48
+ functions=_objects(payload.get("functions", ()), default_kind="function"),
49
+ refreshed_at=float(payload.get("refreshed_at", 0.0) or 0.0),
50
+ )
51
+
52
+ def to_dict(self) -> dict[str, Any]:
53
+ return {
54
+ "schemas": list(self.schemas),
55
+ "objects": [asdict(item) for item in self.objects],
56
+ "columns": [asdict(item) for item in self.columns],
57
+ "functions": [asdict(item) for item in self.functions],
58
+ "refreshed_at": self.refreshed_at,
59
+ }
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class SqlCompletionRequest:
64
+ body: str
65
+ prefix: str
66
+ cursor_pos: int
67
+ cursor_row: int
68
+ cursor_col: int
69
+
70
+ @classmethod
71
+ def from_payload(cls, payload: Mapping[str, Any]) -> "SqlCompletionRequest":
72
+ body = payload.get("body")
73
+ prefix = payload.get("prefix")
74
+ cursor_pos = payload.get("cursor_pos")
75
+ cursor_row = payload.get("cursor_row")
76
+ cursor_col = payload.get("cursor_col")
77
+ if not isinstance(body, str) or not isinstance(prefix, str):
78
+ raise ValueError("SQL completion requires string body and prefix")
79
+ if type(cursor_pos) is not int or not 0 <= cursor_pos <= len(body):
80
+ raise ValueError("SQL completion cursor_pos is outside body")
81
+ if prefix != body[:cursor_pos]:
82
+ raise ValueError("SQL completion prefix does not match body before cursor")
83
+ expected_row = prefix.count("\n")
84
+ expected_col = len(prefix.rsplit("\n", 1)[-1])
85
+ if cursor_row != expected_row or cursor_col != expected_col:
86
+ raise ValueError("SQL completion cursor coordinates are inconsistent")
87
+ return cls(body, prefix, cursor_pos, expected_row, expected_col)
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class CompletionItem:
92
+ text: str
93
+ start: int
94
+ end: int
95
+ label: str = ""
96
+ detail: str = ""
97
+ documentation: str = ""
98
+ kind: str = ""
99
+
100
+ def to_dict(self) -> dict[str, Any]:
101
+ item: dict[str, Any] = {"text": self.text, "start": self.start, "end": self.end}
102
+ for key in ("label", "detail", "documentation", "kind"):
103
+ value = getattr(self, key)
104
+ if value:
105
+ item[key] = value
106
+ return item
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class QueryRelation:
111
+ schema: str
112
+ table: str
113
+ alias: str = ""
114
+
115
+ @property
116
+ def completion_prefix(self) -> str:
117
+ return self.alias or self.table
118
+
119
+
120
+ def complete_sql(
121
+ snapshot: MetadataSnapshot,
122
+ request: SqlCompletionRequest | Mapping[str, Any],
123
+ *,
124
+ keywords: Iterable[str] = (),
125
+ schema_detail: str = "schema",
126
+ relation_keywords: Iterable[str] = RELATION_KEYWORDS,
127
+ ) -> dict[str, list[dict[str, Any]]]:
128
+ """Complete SQL using provider metadata and 1.0 absolute source ranges."""
129
+ if not isinstance(request, SqlCompletionRequest):
130
+ request = SqlCompletionRequest.from_payload(request)
131
+ relation_words = {str(item).upper() for item in relation_keywords}
132
+ token, start = _token_at_cursor(request.prefix, relation_words)
133
+ relation_context = _after_relation_keyword(request.prefix, relation_words)
134
+ parts = token.split(".") if token else []
135
+ relations = parse_query_relations(request.prefix)
136
+ items: list[CompletionItem] = []
137
+ seen: set[tuple[str, str]] = set()
138
+
139
+ def add(
140
+ text: str,
141
+ kind: str,
142
+ *,
143
+ label: str = "",
144
+ detail: str = "",
145
+ documentation: str = "",
146
+ ) -> None:
147
+ if not _candidate_matches(text, token):
148
+ return
149
+ key = (text, kind)
150
+ if key in seen:
151
+ return
152
+ seen.add(key)
153
+ items.append(CompletionItem(text, start, request.cursor_pos, label or text, detail, documentation, kind))
154
+
155
+ if len(parts) <= 1:
156
+ if not relation_context:
157
+ for keyword in keywords:
158
+ add(str(keyword), "keyword", detail="keyword")
159
+ for schema in snapshot.schemas:
160
+ add(schema, "schema", detail=schema_detail)
161
+ for obj in snapshot.objects:
162
+ add(obj.name, obj.kind, detail=obj.schema or obj.detail, documentation=obj.detail)
163
+ if obj.schema:
164
+ add(f"{obj.schema}.{obj.name}", obj.kind, label=obj.name, detail=obj.schema, documentation=obj.detail)
165
+ if not relation_context:
166
+ for function in snapshot.functions:
167
+ add(function.name, "function", detail=function.schema, documentation=function.detail)
168
+ for column in snapshot.columns:
169
+ add(column.name, "column", detail=_column_owner(column), documentation=column.data_type)
170
+ elif len(parts) == 2:
171
+ owner = parts[0].strip('"`').lower()
172
+ for obj in snapshot.objects:
173
+ if obj.schema.lower() == owner:
174
+ add(f"{obj.schema}.{obj.name}", obj.kind, label=obj.name, detail=obj.schema, documentation=obj.detail)
175
+ if not relation_context:
176
+ for function in snapshot.functions:
177
+ if function.schema.lower() == owner:
178
+ add(f"{function.schema}.{function.name}", "function", label=function.name, detail=function.schema, documentation=function.detail)
179
+ for column in snapshot.columns:
180
+ owners = {column.table.lower()}
181
+ owners.update(relation.completion_prefix.lower() for relation in relations if _relation_matches(column, relation))
182
+ if owner in owners:
183
+ add(f"{parts[0]}.{column.name}", "column", label=column.name, detail=_column_owner(column), documentation=column.data_type)
184
+ else:
185
+ schema = parts[-3].strip('"`').lower()
186
+ table = parts[-2].strip('"`').lower()
187
+ for column in snapshot.columns:
188
+ if column.schema.lower() == schema and column.table.lower() == table:
189
+ add(f"{'.'.join(parts[:-1])}.{column.name}", "column", label=column.name, detail=_column_owner(column), documentation=column.data_type)
190
+
191
+ return {"items": [item.to_dict() for item in items[:500]]}
192
+
193
+
194
+ def parse_query_relations(sql: str) -> list[QueryRelation]:
195
+ relations: list[QueryRelation] = []
196
+ for statement in sqlparse.parse(sql):
197
+ _collect_relations(statement, relations)
198
+ unique: list[QueryRelation] = []
199
+ seen: set[tuple[str, str, str]] = set()
200
+ for relation in relations:
201
+ key = (relation.schema.lower(), relation.table.lower(), relation.alias.lower())
202
+ if key not in seen:
203
+ seen.add(key)
204
+ unique.append(relation)
205
+ return unique
206
+
207
+
208
+ def _candidate_matches(candidate: str, token: str) -> bool:
209
+ if not token:
210
+ return True
211
+ candidate_parts = candidate.lower().split(".")
212
+ token_parts = token.lower().split(".")
213
+ if len(token_parts) == 1:
214
+ return candidate_parts[-1].startswith(token_parts[0]) or candidate.lower().startswith(token.lower())
215
+ if len(candidate_parts) < len(token_parts):
216
+ return False
217
+ return candidate_parts[:-1] == token_parts[:-1] and candidate_parts[-1].startswith(token_parts[-1])
218
+
219
+
220
+ def _token_at_cursor(prefix: str, relation_keywords: set[str]) -> tuple[str, int]:
221
+ if _after_relation_keyword(prefix, relation_keywords):
222
+ return "", len(prefix)
223
+ match = _IDENTIFIER_AT_CURSOR.search(prefix)
224
+ if match is None:
225
+ return "", len(prefix)
226
+ return match.group(0), match.start()
227
+
228
+
229
+ def _after_relation_keyword(prefix: str, relation_keywords: set[str]) -> bool:
230
+ if not prefix or not prefix[-1].isspace():
231
+ return False
232
+ words = re.findall(r"[A-Za-z_][A-Za-z0-9_]*", prefix)
233
+ return bool(words and words[-1].upper() in relation_keywords)
234
+
235
+
236
+ def _collect_relations(tokens: TokenList, relations: list[QueryRelation]) -> None:
237
+ values = list(tokens.tokens)
238
+ for index, token in enumerate(values):
239
+ if token.is_group:
240
+ _collect_relations(token, relations)
241
+ normalized = str(getattr(token, "normalized", "") or "").upper()
242
+ if token.ttype is not Keyword or not (normalized in {"FROM", "UPDATE", "INTO", "TABLE"} or normalized.endswith("JOIN")):
243
+ continue
244
+ next_token = _next_meaningful(values[index + 1 :])
245
+ if isinstance(next_token, IdentifierList):
246
+ for identifier in next_token.get_identifiers():
247
+ _append_relation(identifier, relations)
248
+ elif isinstance(next_token, Identifier):
249
+ _append_relation(next_token, relations)
250
+ elif next_token is not None and next_token.ttype in (Name, Keyword):
251
+ relations.append(QueryRelation("", str(next_token.value), ""))
252
+
253
+
254
+ def _append_relation(identifier: Identifier, relations: list[QueryRelation]) -> None:
255
+ if any(token.is_group and token.value.strip().startswith("(") for token in identifier.tokens):
256
+ return
257
+ table = identifier.get_real_name() or ""
258
+ if table:
259
+ relations.append(QueryRelation(identifier.get_parent_name() or "", table, identifier.get_alias() or ""))
260
+
261
+
262
+ def _next_meaningful(tokens: Sequence[Any]) -> Any:
263
+ return next((token for token in tokens if not token.is_whitespace), None)
264
+
265
+
266
+ def _relation_matches(column: CompletionColumn, relation: QueryRelation) -> bool:
267
+ return column.table.lower() == relation.table.lower() and (
268
+ not relation.schema or column.schema.lower() == relation.schema.lower()
269
+ )
270
+
271
+
272
+ def _column_owner(column: CompletionColumn) -> str:
273
+ return f"{column.schema}.{column.table}" if column.schema else column.table
274
+
275
+
276
+ def _objects(values: Any, *, default_kind: str) -> list[CompletionObject]:
277
+ if not isinstance(values, Sequence) or isinstance(values, (str, bytes)):
278
+ return []
279
+ result: list[CompletionObject] = []
280
+ for item in values:
281
+ if isinstance(item, Mapping) and str(item.get("name", "")):
282
+ result.append(CompletionObject(
283
+ name=str(item["name"]),
284
+ schema=str(item.get("schema", "")),
285
+ kind=str(item.get("kind", default_kind)) or default_kind,
286
+ detail=str(item.get("detail", "")),
287
+ ))
288
+ return result
289
+
290
+
291
+ def _columns(values: Any) -> list[CompletionColumn]:
292
+ if not isinstance(values, Sequence) or isinstance(values, (str, bytes)):
293
+ return []
294
+ result: list[CompletionColumn] = []
295
+ for item in values:
296
+ if isinstance(item, Mapping) and str(item.get("table", "")) and str(item.get("name", "")):
297
+ result.append(CompletionColumn(
298
+ table=str(item["table"]),
299
+ name=str(item["name"]),
300
+ schema=str(item.get("schema", "")),
301
+ data_type=str(item.get("data_type", "")),
302
+ ))
303
+ return result
jusi_sql/config.py ADDED
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from dataclasses import dataclass
5
+ from typing import Any, Iterable, Mapping
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class SqlProviderIdentity:
10
+ plugin_id: str
11
+ plugin_version: str
12
+ selectors: tuple[str, ...]
13
+
14
+ @classmethod
15
+ def create(
16
+ cls,
17
+ plugin_id: str,
18
+ plugin_version: str,
19
+ selectors: Iterable[str] = (),
20
+ ) -> "SqlProviderIdentity":
21
+ normalized_id = _nonempty(plugin_id, "plugin_id")
22
+ normalized = tuple(dict.fromkeys(_nonempty(item, "provider selector") for item in selectors))
23
+ return cls(normalized_id, _nonempty(plugin_version, "plugin_version"), normalized or (normalized_id,))
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class SqlTarget:
28
+ alias: str
29
+ provider: str
30
+ options: dict[str, Any]
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ResolvedSqlTarget:
35
+ alias: str
36
+ provider: str
37
+ plugin_id: str
38
+ plugin_version: str
39
+ options: dict[str, Any]
40
+
41
+
42
+ class SqlConfigError(ValueError):
43
+ """A secret-safe SQL family configuration error."""
44
+
45
+ def __init__(self, message: str, *, reason: str = "invalid_config") -> None:
46
+ self.reason = reason
47
+ super().__init__(message)
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class SqlFamilyConfig:
52
+ targets: dict[str, SqlTarget]
53
+
54
+ @classmethod
55
+ def from_mapping(cls, configuration: Mapping[str, Any] | None) -> "SqlFamilyConfig":
56
+ root = configuration or {}
57
+ if not isinstance(root, Mapping):
58
+ raise SqlConfigError("Jusi runtime configuration must be an object")
59
+ section = root.get("sql")
60
+ if section is None:
61
+ return cls({})
62
+ if not isinstance(section, Mapping):
63
+ raise SqlConfigError("[sql] must be a table")
64
+
65
+ # 1.0's canonical shape is [sql.targets.<alias>]. Direct [sql.<alias>]
66
+ # remains accepted so existing target files can migrate independently.
67
+ if "targets" in section:
68
+ raw_targets = section["targets"]
69
+ legacy = [key for key in section if key != "targets"]
70
+ if legacy:
71
+ raise SqlConfigError("Do not mix [sql.targets.*] with legacy [sql.*] targets")
72
+ else:
73
+ raw_targets = section
74
+ if not isinstance(raw_targets, Mapping):
75
+ raise SqlConfigError("[sql.targets] must be a table")
76
+
77
+ targets: dict[str, SqlTarget] = {}
78
+ for raw_alias, raw_target in raw_targets.items():
79
+ alias = str(raw_alias).strip()
80
+ if not alias:
81
+ raise SqlConfigError("SQL target aliases must be non-empty")
82
+ if not isinstance(raw_target, Mapping):
83
+ raise SqlConfigError(f"SQL target {alias!r} must be a table")
84
+ provider = str(raw_target.get("provider", "")).strip()
85
+ if not provider:
86
+ raise SqlConfigError(f"SQL target {alias!r} must define provider")
87
+ options = {
88
+ str(key): deepcopy(value)
89
+ for key, value in raw_target.items()
90
+ if str(key) != "provider"
91
+ }
92
+ targets[alias] = SqlTarget(alias, provider, options)
93
+ return cls(targets)
94
+
95
+ def resolve(
96
+ self,
97
+ alias: str,
98
+ providers: Iterable[SqlProviderIdentity],
99
+ ) -> ResolvedSqlTarget:
100
+ normalized = str(alias).strip()
101
+ if not normalized:
102
+ raise SqlConfigError("%%sql requires a target alias", reason="missing_alias")
103
+ target = self.targets.get(normalized)
104
+ if target is None:
105
+ raise SqlConfigError(f"Unknown SQL target alias {normalized!r}", reason="unknown_alias")
106
+ matches = [provider for provider in providers if target.provider in provider.selectors]
107
+ if not matches:
108
+ raise SqlConfigError(
109
+ f"SQL target {normalized!r} selects unavailable provider {target.provider!r}",
110
+ reason="unavailable_provider",
111
+ )
112
+ if len(matches) > 1:
113
+ raise SqlConfigError(
114
+ f"SQL target {normalized!r} selects ambiguous provider {target.provider!r}",
115
+ reason="ambiguous_provider",
116
+ )
117
+ provider = matches[0]
118
+ return ResolvedSqlTarget(
119
+ alias=target.alias,
120
+ provider=target.provider,
121
+ plugin_id=provider.plugin_id,
122
+ plugin_version=provider.plugin_version,
123
+ options=deepcopy(target.options),
124
+ )
125
+
126
+
127
+ def resolve_sql_target(
128
+ alias: str,
129
+ configuration: Mapping[str, Any] | None,
130
+ providers: Iterable[SqlProviderIdentity],
131
+ ) -> ResolvedSqlTarget:
132
+ return SqlFamilyConfig.from_mapping(configuration).resolve(alias, providers)
133
+
134
+
135
+ def _nonempty(value: object, name: str) -> str:
136
+ normalized = str(value).strip()
137
+ if not normalized:
138
+ raise ValueError(f"{name} must be a non-empty string")
139
+ return normalized
jusi_sql/kernel.py ADDED
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ import shlex
4
+ from copy import deepcopy
5
+ from dataclasses import dataclass
6
+ from typing import Any, Iterable, Mapping
7
+
8
+ from .catalog import FAMILY_ID, MAGIC_NAME
9
+ from .config import SqlConfigError, SqlFamilyConfig, SqlProviderIdentity
10
+
11
+
12
+ HANDOFF_MIME = "application/vnd.jusi.handoff.v1+json"
13
+ _DISPATCHER_MARKER = "_jusi_sql_dispatcher_v1"
14
+ _providers: dict[str, "SqlKernelAdapter"] = {}
15
+ _configuration: dict[str, Any] | None = None
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class SqlMagicArguments:
20
+ alias: str
21
+
22
+
23
+ class SqlKernelAdapter:
24
+ """Exact-provider adapter backed by one shared ``%%sql`` dispatcher."""
25
+
26
+ def __init__(
27
+ self,
28
+ *,
29
+ plugin_id: str,
30
+ plugin_version: str,
31
+ selectors: Iterable[str] = (),
32
+ empty_sql: str = "",
33
+ ) -> None:
34
+ self.identity = SqlProviderIdentity.create(plugin_id, plugin_version, selectors)
35
+ self.empty_sql = str(empty_sql)
36
+ self._configuration: dict[str, Any] = {}
37
+
38
+ def manifest(self) -> dict[str, Any]:
39
+ return {
40
+ "plugin_id": self.identity.plugin_id,
41
+ "plugin_version": self.identity.plugin_version,
42
+ "families": [{"family_id": FAMILY_ID, "magic_name": MAGIC_NAME}],
43
+ }
44
+
45
+ def configure(self, configuration: Mapping[str, Any]) -> None:
46
+ if not isinstance(configuration, Mapping):
47
+ raise TypeError("Jusi runtime configuration must be an object")
48
+ self._configuration = deepcopy(dict(configuration))
49
+
50
+ def load(self, ipython: Any) -> None:
51
+ _register_provider(self)
52
+ _configure_family(self._configuration)
53
+ _register_dispatcher(ipython)
54
+
55
+
56
+ def parse_sql_magic_line(line: str) -> SqlMagicArguments:
57
+ try:
58
+ parts = shlex.split(line)
59
+ except ValueError as exc:
60
+ raise SqlConfigError(f"Invalid %%sql arguments: {exc}", reason="invalid_magic_line") from exc
61
+ if not parts:
62
+ raise SqlConfigError("%%sql requires a target alias", reason="missing_alias")
63
+ if len(parts) != 1:
64
+ raise SqlConfigError(
65
+ "%%sql accepts one target alias; put provider options in [sql.targets.<alias>]",
66
+ reason="invalid_magic_line",
67
+ )
68
+ return SqlMagicArguments(parts[0])
69
+
70
+
71
+ def dispatch_sql(line: str, cell: str) -> dict[str, Any]:
72
+ arguments = parse_sql_magic_line(line)
73
+ family = SqlFamilyConfig.from_mapping(_configuration)
74
+ target = family.resolve(arguments.alias, (item.identity for item in _providers.values()))
75
+ adapter = _providers[target.plugin_id]
76
+ sql = str(cell)
77
+ if not sql.strip() and adapter.empty_sql:
78
+ sql = adapter.empty_sql
79
+ return {
80
+ "protocol_version": 1,
81
+ "kind": "plugin.handoff",
82
+ "plugin_id": target.plugin_id,
83
+ "plugin_version": target.plugin_version,
84
+ "family_id": FAMILY_ID,
85
+ "magic_name": MAGIC_NAME,
86
+ "payload": {
87
+ "alias": target.alias,
88
+ "sql": sql,
89
+ "options": target.options,
90
+ },
91
+ }
92
+
93
+
94
+ def _register_provider(adapter: SqlKernelAdapter) -> None:
95
+ current = _providers.get(adapter.identity.plugin_id)
96
+ if current is not None and current is not adapter:
97
+ raise RuntimeError(f"SQL provider {adapter.identity.plugin_id!r} registered more than once")
98
+ for existing in _providers.values():
99
+ overlap = set(existing.identity.selectors) & set(adapter.identity.selectors)
100
+ if existing is not adapter and overlap:
101
+ names = ", ".join(sorted(overlap))
102
+ raise RuntimeError(f"SQL provider selectors are ambiguous: {names}")
103
+ _providers[adapter.identity.plugin_id] = adapter
104
+
105
+
106
+ def _configure_family(configuration: Mapping[str, Any]) -> None:
107
+ global _configuration
108
+ candidate = deepcopy(dict(configuration))
109
+ if _configuration is not None and _configuration != candidate:
110
+ raise RuntimeError("SQL adapters received inconsistent runtime configuration")
111
+ _configuration = candidate
112
+
113
+
114
+ def _register_dispatcher(ipython: Any) -> None:
115
+ if getattr(ipython, _DISPATCHER_MARKER, False):
116
+ return
117
+ cell_magics = getattr(getattr(ipython, "magics_manager", None), "magics", {}).get("cell", {})
118
+ if MAGIC_NAME in cell_magics:
119
+ raise RuntimeError("The sql cell magic is already owned by another extension")
120
+
121
+ def sql_magic(line: str, cell: str) -> None:
122
+ from IPython.core.error import UsageError
123
+ from IPython.display import display
124
+
125
+ try:
126
+ handoff = dispatch_sql(line, cell)
127
+ except SqlConfigError as exc:
128
+ raise UsageError(str(exc)) from exc
129
+ display({HANDOFF_MIME: handoff}, raw=True)
130
+
131
+ ipython.register_magic_function(sql_magic, magic_kind="cell", magic_name=MAGIC_NAME)
132
+ setattr(ipython, _DISPATCHER_MARKER, True)
133
+
134
+
135
+ def _reset_runtime_for_tests() -> None:
136
+ global _configuration
137
+ _providers.clear()
138
+ _configuration = None
jusi_sql/metadata.py ADDED
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import threading
8
+ import time
9
+ from copy import deepcopy
10
+ from pathlib import Path
11
+ from typing import Any, Callable, Mapping
12
+
13
+ from .completion import MetadataSnapshot
14
+
15
+
16
+ DEFAULT_METADATA_TTL = 60 * 60.0
17
+ _SECRET_MARKERS = ("password", "passphrase", "secret", "token", "api_key", "private_key", "sslkey", "krb5ccname")
18
+
19
+
20
+ class MetadataCache:
21
+ """Thread-safe stale-while-refresh cache shared by SQL providers."""
22
+
23
+ def __init__(
24
+ self,
25
+ cache_directory: Path,
26
+ loader: Callable[[], MetadataSnapshot],
27
+ *,
28
+ ttl: float = DEFAULT_METADATA_TTL,
29
+ on_warning: Callable[[str], None] | None = None,
30
+ ) -> None:
31
+ self.path = Path(cache_directory) / "metadata.json"
32
+ self.loader = loader
33
+ self.ttl = max(0.0, float(ttl))
34
+ self.on_warning = on_warning
35
+ self._lock = threading.Lock()
36
+ self._refreshing = False
37
+ self._thread: threading.Thread | None = None
38
+ self._snapshot = MetadataSnapshot.from_dict(_read_json(self.path))
39
+
40
+ def snapshot(self) -> MetadataSnapshot:
41
+ with self._lock:
42
+ return MetadataSnapshot.from_dict(self._snapshot.to_dict())
43
+
44
+ def mark_stale(self) -> None:
45
+ with self._lock:
46
+ self._snapshot.refreshed_at = 0.0
47
+
48
+ def ensure_fresh_async(self, *, force: bool = False) -> bool:
49
+ with self._lock:
50
+ age = time.time() - self._snapshot.refreshed_at if self._snapshot.refreshed_at else float("inf")
51
+ if self._refreshing or (not force and age < self.ttl):
52
+ return False
53
+ self._refreshing = True
54
+ self._thread = threading.Thread(target=self._refresh, name="jusi-sql-metadata", daemon=True)
55
+ self._thread.start()
56
+ return True
57
+
58
+ def close(self, *, timeout: float = 2.0) -> bool:
59
+ with self._lock:
60
+ thread = self._thread
61
+ if thread is None or not thread.is_alive():
62
+ return True
63
+ thread.join(timeout=max(0.0, timeout))
64
+ return not thread.is_alive()
65
+
66
+ def _refresh(self) -> None:
67
+ try:
68
+ snapshot = self.loader()
69
+ if not isinstance(snapshot, MetadataSnapshot):
70
+ raise TypeError("metadata loader must return MetadataSnapshot")
71
+ snapshot.refreshed_at = time.time()
72
+ _write_json(self.path, snapshot.to_dict())
73
+ with self._lock:
74
+ self._snapshot = snapshot
75
+ except Exception as exc:
76
+ if self.on_warning is not None:
77
+ self.on_warning(f"SQL metadata refresh failed: {exc.__class__.__name__}: {exc}")
78
+ finally:
79
+ with self._lock:
80
+ self._refreshing = False
81
+
82
+
83
+ def sql_cache_directory(
84
+ provider_id: str,
85
+ alias: str,
86
+ options: Mapping[str, Any],
87
+ *,
88
+ state_home: Path | None = None,
89
+ ) -> Path:
90
+ """Return a stable cache path whose fingerprint never includes secret values."""
91
+ root = state_home or _default_state_home()
92
+ redacted = _redact_secrets(options)
93
+ encoded = json.dumps(redacted, ensure_ascii=True, sort_keys=True, separators=(",", ":"), default=str)
94
+ digest = hashlib.sha256(encoded.encode("utf-8")).hexdigest()[:16]
95
+ return root / "plugins" / _safe_segment(provider_id) / _safe_segment(alias) / digest
96
+
97
+
98
+ def _default_state_home() -> Path:
99
+ override = os.environ.get("JUSI_STATE_HOME", "").strip()
100
+ if override:
101
+ return Path(os.path.expanduser(override)).resolve()
102
+ xdg_state = os.environ.get("XDG_STATE_HOME", "").strip()
103
+ if xdg_state:
104
+ return (Path(os.path.expanduser(xdg_state)) / "jusi").resolve()
105
+ return (Path.home() / ".local" / "state" / "jusi").resolve()
106
+
107
+
108
+ def _redact_secrets(value: Any, *, key: str = "") -> Any:
109
+ lowered = key.lower().replace("-", "_")
110
+ if any(marker in lowered for marker in _SECRET_MARKERS):
111
+ return "<redacted>"
112
+ if isinstance(value, Mapping):
113
+ return {str(item_key): _redact_secrets(item, key=str(item_key)) for item_key, item in value.items()}
114
+ if isinstance(value, (list, tuple)):
115
+ return [_redact_secrets(item) for item in value]
116
+ return deepcopy(value)
117
+
118
+
119
+ def _safe_segment(value: object) -> str:
120
+ normalized = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(value).strip()).strip("-._")
121
+ return normalized[:120] or "default"
122
+
123
+
124
+ def _read_json(path: Path) -> dict[str, Any]:
125
+ try:
126
+ value = json.loads(path.read_text(encoding="utf-8"))
127
+ except (FileNotFoundError, OSError, UnicodeError, json.JSONDecodeError):
128
+ return {}
129
+ return value if isinstance(value, dict) else {}
130
+
131
+
132
+ def _write_json(path: Path, payload: Mapping[str, Any]) -> None:
133
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
134
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
135
+ descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
136
+ try:
137
+ with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
138
+ json.dump(dict(payload), stream, ensure_ascii=True, sort_keys=True)
139
+ stream.write("\n")
140
+ temporary.replace(path)
141
+ except BaseException:
142
+ temporary.unlink(missing_ok=True)
143
+ raise
jusi_sql/visidata.py ADDED
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Callable
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class SqlSheetActions:
9
+ """Provider-owned behavior exposed through shared VisiData commands."""
10
+
11
+ fetch_more: Callable[[int], int] | None = None
12
+ commit: Callable[[], None] | None = None
13
+ rollback: Callable[[], None] | None = None
14
+
15
+
16
+ def bind_sql_actions(sheet: Any, actions: SqlSheetActions) -> None:
17
+ """Attach actions to one result sheet without a process-global session."""
18
+ setattr(sheet, "jusi_sql_actions", actions)
19
+
20
+
21
+ def find_sql_actions(sheet: Any) -> SqlSheetActions | None:
22
+ """Find the owning result sheet from a VisiData-derived sheet chain."""
23
+ current = sheet
24
+ seen: set[int] = set()
25
+ while current is not None:
26
+ marker = id(current)
27
+ if marker in seen:
28
+ return None
29
+ seen.add(marker)
30
+ actions = getattr(current, "jusi_sql_actions", None)
31
+ if isinstance(actions, SqlSheetActions):
32
+ return actions
33
+ current = getattr(current, "source", None)
34
+ return None
35
+
36
+
37
+ def install_visidata_commands(visidata_module: Any | None = None) -> None:
38
+ """Install provider-neutral fetch/transaction commands once per application."""
39
+ if visidata_module is None:
40
+ import visidata as visidata_module # type: ignore[no-redef]
41
+ base_sheet = visidata_module.BaseSheet
42
+ vd = visidata_module.vd
43
+ marker = "_jusi_sql_family_commands_v1"
44
+ if getattr(base_sheet, marker, False):
45
+ return
46
+
47
+ def actions_for(sheet: Any) -> SqlSheetActions | None:
48
+ actions = find_sql_actions(sheet)
49
+ if actions is None:
50
+ vd.warning("No SQL result session owns this sheet")
51
+ return actions
52
+
53
+ for number in range(1, 10):
54
+ def fetch(sheet: Any, count: int = number) -> None:
55
+ actions = actions_for(sheet)
56
+ if actions is None:
57
+ return
58
+ if actions.fetch_more is None:
59
+ vd.warning("This SQL provider does not support incremental fetch")
60
+ return
61
+ actions.fetch_more(count)
62
+
63
+ base_sheet.command(
64
+ str(number),
65
+ f"jusi-sql-fetch-{number}",
66
+ f"fetch {number} more SQL rows",
67
+ replay=False,
68
+ )(fetch)
69
+
70
+ @base_sheet.command("gf", "jusi-sql-fetch-prompt", "fetch more SQL rows", replay=False)
71
+ def fetch_prompt(sheet: Any) -> None:
72
+ actions = actions_for(sheet)
73
+ if actions is None:
74
+ return
75
+ if actions.fetch_more is None:
76
+ vd.warning("This SQL provider does not support incremental fetch")
77
+ return
78
+ raw = vd.input("fetch rows (0 for all): ")
79
+ try:
80
+ count = int(str(raw).strip())
81
+ except ValueError:
82
+ vd.warning("Fetch count must be a number")
83
+ return
84
+ if count < 0:
85
+ vd.warning("Fetch count must be at least 0")
86
+ return
87
+ actions.fetch_more(count)
88
+
89
+ @base_sheet.command("gc", "jusi-sql-commit", "commit SQL transaction", replay=False)
90
+ def commit(sheet: Any) -> None:
91
+ actions = actions_for(sheet)
92
+ if actions is None:
93
+ return
94
+ if actions.commit is None:
95
+ vd.warning("This SQL provider does not support transactions")
96
+ return
97
+ actions.commit()
98
+
99
+ @base_sheet.command("gr", "jusi-sql-rollback", "roll back SQL transaction", replay=False)
100
+ def rollback(sheet: Any) -> None:
101
+ actions = actions_for(sheet)
102
+ if actions is None:
103
+ return
104
+ if actions.rollback is None:
105
+ vd.warning("This SQL provider does not support transactions")
106
+ return
107
+ actions.rollback()
108
+
109
+ setattr(base_sheet, marker, True)
jusi_sql/worker.py ADDED
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import tempfile
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any, Callable, Mapping, Protocol, Union
10
+
11
+ from jusi.plugin_api import OperationRejected, WorkerContext, WorkerResult
12
+
13
+ from .completion import CompletionItem, SqlCompletionRequest
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class SqlExecuteRequest:
18
+ alias: str
19
+ sql: str
20
+ options: dict[str, Any]
21
+
22
+ @classmethod
23
+ def from_payload(cls, payload: Mapping[str, Any]) -> "SqlExecuteRequest":
24
+ alias = payload.get("alias")
25
+ sql = payload.get("sql")
26
+ options = payload.get("options")
27
+ if not isinstance(alias, str) or not alias.strip():
28
+ raise OperationRejected("SQL execute requires an alias", reason="invalid_request")
29
+ if not isinstance(sql, str):
30
+ raise OperationRejected("SQL execute requires string sql", reason="invalid_request")
31
+ if not isinstance(options, dict):
32
+ raise OperationRejected("SQL execute requires provider options", reason="invalid_request")
33
+ return cls(alias.strip(), sql, dict(options))
34
+
35
+
36
+ class SqlSession(Protocol):
37
+ def execute(self, request: SqlExecuteRequest) -> Union[WorkerResult, dict[str, Any]]: ...
38
+ def followup(self, body: str) -> Union[WorkerResult, dict[str, Any]]: ...
39
+ def complete(self, request: SqlCompletionRequest) -> Any: ...
40
+ def interrupt(self) -> None: ...
41
+ def editor_action(self, action: str, selection: dict[str, Any]) -> WorkerResult: ...
42
+ def close(self) -> None: ...
43
+
44
+
45
+ class SqlWorker:
46
+ """Composition-based 1.0 operation router for one exact SQL session."""
47
+
48
+ def __init__(
49
+ self,
50
+ context: WorkerContext,
51
+ session_factory: Callable[[WorkerContext], SqlSession],
52
+ ) -> None:
53
+ self.context = context
54
+ self._session = session_factory(context)
55
+ self._executed = False
56
+ self._closed = False
57
+
58
+ def handle(self, operation: str, payload: dict[str, Any]) -> WorkerResult:
59
+ if self._closed:
60
+ raise OperationRejected("SQL session is closed", reason="conflict")
61
+ if operation == "execute":
62
+ if self._executed:
63
+ raise OperationRejected("SQL session was already started", reason="conflict")
64
+ request = SqlExecuteRequest.from_payload(payload)
65
+ result = self._session.execute(request)
66
+ self._executed = True
67
+ return _worker_result(result)
68
+ if not self._executed:
69
+ raise OperationRejected("SQL session has not started", reason="conflict")
70
+ if operation == "followup":
71
+ body = payload.get("body")
72
+ if not isinstance(body, str):
73
+ raise OperationRejected("SQL followup requires string body", reason="invalid_request")
74
+ return _worker_result(self._session.followup(body))
75
+ if operation == "complete":
76
+ try:
77
+ request = SqlCompletionRequest.from_payload(payload)
78
+ except ValueError as exc:
79
+ raise OperationRejected(str(exc), reason="invalid_request") from exc
80
+ completed = self._session.complete(request)
81
+ if isinstance(completed, dict) and isinstance(completed.get("items"), list):
82
+ return WorkerResult(completed)
83
+ items = [item.to_dict() if isinstance(item, CompletionItem) else dict(item) for item in completed]
84
+ return WorkerResult({"items": items})
85
+ if operation == "editor_action":
86
+ action = payload.get("action")
87
+ selection = payload.get("selection")
88
+ if action not in {"copy", "open", "show_diff"} or not isinstance(selection, dict):
89
+ raise OperationRejected("Invalid SQL editor action", reason="invalid_request")
90
+ return self._session.editor_action(action, selection)
91
+ raise OperationRejected(f"Unsupported SQL operation {operation!r}", reason="unsupported")
92
+
93
+ def interrupt(self) -> None:
94
+ # Jusi invokes this concurrently. The provider hook must be prompt and
95
+ # thread-safe; this router deliberately takes no ordinary-operation lock.
96
+ if not self._closed:
97
+ self._session.interrupt()
98
+
99
+ def close(self) -> None:
100
+ if not self._closed:
101
+ self._closed = True
102
+ self._session.close()
103
+
104
+
105
+ class StagedApplicationPayload:
106
+ """A private, client-owned JSON handoff for a terminal application."""
107
+
108
+ def __init__(self, payload: Mapping[str, Any], *, prefix: str = "jusi-sql-") -> None:
109
+ self.directory = Path(tempfile.mkdtemp(prefix=prefix))
110
+ self.directory.chmod(0o700)
111
+ self.path = self.directory / "payload.json"
112
+ descriptor = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
113
+ try:
114
+ with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
115
+ json.dump(dict(payload), stream, ensure_ascii=False)
116
+ except BaseException:
117
+ self.close()
118
+ raise
119
+
120
+ def close(self) -> None:
121
+ shutil.rmtree(self.directory, ignore_errors=True)
122
+
123
+
124
+ def _worker_result(value: Union[WorkerResult, dict[str, Any]]) -> WorkerResult:
125
+ return value if isinstance(value, WorkerResult) else WorkerResult(dict(value))
@@ -0,0 +1,175 @@
1
+ Metadata-Version: 2.5
2
+ Name: jusi-sql
3
+ Version: 0.2.0
4
+ Summary: The SQL plugin-family contract and reusable provider toolkit for Jusi 1.0
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<2,>=1.0
30
+ Requires-Dist: sqlparse<0.6,>=0.5
31
+ Provides-Extra: test
32
+ Requires-Dist: pytest<9,>=8; extra == 'test'
33
+ Provides-Extra: visidata
34
+ Requires-Dist: visidata<4,>=3; extra == 'visidata'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # jusi-sql
38
+
39
+ `jusi-sql` is the independently installable SQL plugin family for Jusi 1.0.
40
+ It owns the user-facing `%%sql` contract and the provider-neutral code that
41
+ SQLite, PostgreSQL, ClickHouse, and future database plugins should share. It is
42
+ not an exact provider and therefore publishes no `jusi.plugins.v1` entry point.
43
+
44
+ ## Family contract
45
+
46
+ Every exact SQL provider advertises the same catalog claim:
47
+
48
+ - family `sql`, magic `sql`
49
+ - capabilities `execute`, `followup`, `complete`, `interrupt`, and
50
+ `editor_actions`
51
+ - shared `sql` syntax and indentation profiles
52
+ - terminal-interactive presentation
53
+
54
+ An exact provider may refine syntax after handoff (for example `pgsql` or
55
+ `clickhouse`), but may not change the shared capabilities or presentation.
56
+ Providers own connections, transactions, dialect metadata queries,
57
+ cancellation, result rendering, selection meaning, and application IPC.
58
+
59
+ The family owns target configuration and selection, deterministic ownership of
60
+ one `%%sql` magic, the initial handoff payload, continuing-operation parsing,
61
+ metadata-aware completion, safe cache paths, and private application staging.
62
+ There is no process-global live-session registry. A worker and its exact
63
+ provider session belong to one client in one notebook-runtime generation.
64
+
65
+ ## Configuration
66
+
67
+ The canonical 1.0 shape is:
68
+
69
+ ```toml
70
+ [sql.targets.warehouse]
71
+ provider = "clickhouse"
72
+ host = "db.internal"
73
+ database = "analytics"
74
+
75
+ [sql.targets.reporting]
76
+ provider = "postgres"
77
+ host = "pg.internal"
78
+ dbname = "reports"
79
+ ```
80
+
81
+ Then a cell starts with `%%sql warehouse`. The provider is resolved only inside
82
+ the target kernel against adapters installed in that runtime. Missing, unknown,
83
+ unavailable, and ambiguous providers fail without printing target options. The
84
+ historical `[sql.warehouse]` shape is accepted during migration, but it cannot
85
+ be mixed with `[sql.targets.*]`.
86
+
87
+ Secrets may be provider options because the target worker needs them. They are
88
+ never placed in catalog metadata or error messages. Prefer provider-owned
89
+ environment or secret resolution where possible.
90
+
91
+ ## Exact-provider integration
92
+
93
+ Catalog code stays lightweight:
94
+
95
+ ```python
96
+ from jusi_sql import sql_catalog_entry
97
+
98
+ def catalog_entry():
99
+ return sql_catalog_entry(
100
+ plugin_id="postgres",
101
+ plugin_version="1.0.0",
102
+ distribution="jusi-postgres",
103
+ kernel_extension="jusi_postgres.kernel",
104
+ worker_entry_point="jusi_postgres.worker:create_worker",
105
+ provider_presentation={"syntax": "pgsql", "indent": "sql"},
106
+ )
107
+ ```
108
+
109
+ The exact kernel module retains its own attested identity while sharing the
110
+ dispatcher:
111
+
112
+ ```python
113
+ from jusi_sql.kernel import SqlKernelAdapter
114
+
115
+ _adapter = SqlKernelAdapter(
116
+ plugin_id="postgres",
117
+ plugin_version="1.0.0",
118
+ selectors=("postgres", "postgresql"),
119
+ empty_sql="SELECT 1 WHERE false",
120
+ )
121
+
122
+ jusi_kernel_adapter_v1 = _adapter.manifest
123
+ configure_jusi_runtime_v1 = _adapter.configure
124
+ load_ipython_extension = _adapter.load
125
+ ```
126
+
127
+ Each installed adapter registers its identity in the shared dispatcher. After
128
+ all catalog modules attest, `%%sql` resolves the alias and emits one exact 1.0
129
+ handoff. Registration rejects selector collisions and an unrelated extension
130
+ already owning `%%sql`; it never relies on import order.
131
+
132
+ Worker code uses composition rather than the retired handler hierarchy:
133
+
134
+ ```python
135
+ from jusi_sql.worker import SqlWorker
136
+
137
+ def create_worker(context):
138
+ return SqlWorker(context, PostgresSession)
139
+ ```
140
+
141
+ `PostgresSession` implements `execute`, `followup`, `complete`, `interrupt`,
142
+ `editor_action`, and `close`. `interrupt` is called concurrently and must be
143
+ prompt and thread-safe. Recoverable database errors should raise
144
+ `jusi.plugin_api.OperationRejected`; broken sessions should fail normally so
145
+ Jusi fences the worker.
146
+
147
+ ## Completion and metadata
148
+
149
+ `complete_sql` consumes a `MetadataSnapshot` and Jusi's full completion payload.
150
+ It returns absolute Unicode code-point ranges, preserves suffix text, handles
151
+ empty prefixes, and understands schema, table, alias, and column qualification.
152
+ Exact providers only collect dialect metadata and supply keyword lists.
153
+
154
+ `MetadataCache` provides stale-while-refresh snapshots.
155
+ `sql_cache_directory` creates stable provider-scoped paths while recursively
156
+ redacting secret values from the fingerprint.
157
+
158
+ Providers using VisiData may install the optional `visidata` extra and use
159
+ `SqlSheetActions`, `bind_sql_actions`, and `install_visidata_commands`. This
160
+ supplies the common numeric/`gf` fetch and `gc`/`gr` transaction commands on
161
+ result sheets and all derived sheets. Live callbacks remain attached to their
162
+ own provider sheet; the family does not keep a global current session.
163
+
164
+ ## Migration from 0.x providers
165
+
166
+ Remove `DisplayHandlerSpec`, `BaseSqlHandler`, `SqlSheetRuntime`, environment
167
+ payloads, and the `jusi plugin-runtime` bootstrap. Split each provider into
168
+ catalog, kernel adapter, worker, and terminal-application import boundaries.
169
+ Keep VisiData and driver imports out of catalog and worker startup.
170
+
171
+ PostgreSQL- and ClickHouse-specific connection, streaming, binary-value,
172
+ Kerberos, transaction, and cancellation code remains in those packages. Their
173
+ duplicated target parsing, magic registration, completion models, relation
174
+ parsing, metadata cache, safe cache path, and operation dispatch should be
175
+ deleted in favor of this family package during their 1.0 migrations.
@@ -0,0 +1,12 @@
1
+ jusi_sql/__init__.py,sha256=a0GYuHasAO2BdL5DD8B1TobM2ApyYLxSR34PZG8PIso,1492
2
+ jusi_sql/catalog.py,sha256=5pRzdWaOU6ukyZNus4cV2yeoxv9GczcmRdtI52oK8e8,1884
3
+ jusi_sql/completion.py,sha256=heihwJkt9XTVDUiJA4_GXY7SL6Dgiphi7mlOCL19Qdw,12000
4
+ jusi_sql/config.py,sha256=8XmIkLMwfe1pbTJv9pAlkGu5BFdS_BBFQF2YrO-2o7M,4854
5
+ jusi_sql/kernel.py,sha256=HO2TybS2vTmCt4SZqJA957n0uO_15mogU2dOv8K7ib0,4888
6
+ jusi_sql/metadata.py,sha256=8nY5Kn1YgjDB3-Rm6I8aKCcHyD-KLKFwYrn8CCfTYYA,5246
7
+ jusi_sql/visidata.py,sha256=2xE1mvobIkUCuHxvufR2OUX2fLsM-wcQeVcnKCprdTk,3775
8
+ jusi_sql/worker.py,sha256=PbmZNQTdFglIc3rEMTa_ODJm6bSt3QbS5MAg7ow8Y9Y,5277
9
+ jusi_sql-0.2.0.dist-info/METADATA,sha256=eMklo1tzLiox6adl8NSck4Cm7CzfSfsp0sAY7xXPfXM,7079
10
+ jusi_sql-0.2.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
11
+ jusi_sql-0.2.0.dist-info/licenses/LICENSE,sha256=YSDMzYJrAu4QMyHcnOSrhmjYzkD9rEKha8G2CH4p3AA,1069
12
+ jusi_sql-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.