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 +63 -0
- jusi_sql/catalog.py +55 -0
- jusi_sql/completion.py +303 -0
- jusi_sql/config.py +139 -0
- jusi_sql/kernel.py +138 -0
- jusi_sql/metadata.py +143 -0
- jusi_sql/visidata.py +109 -0
- jusi_sql/worker.py +125 -0
- jusi_sql-0.2.0.dist-info/METADATA +175 -0
- jusi_sql-0.2.0.dist-info/RECORD +12 -0
- jusi_sql-0.2.0.dist-info/WHEEL +4 -0
- jusi_sql-0.2.0.dist-info/licenses/LICENSE +21 -0
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,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.
|