sqlseed-web 0.2.4__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.
- sqlseed_web/AGENTS.md +106 -0
- sqlseed_web/__init__.py +24 -0
- sqlseed_web/__main__.py +8 -0
- sqlseed_web/_application.py +205 -0
- sqlseed_web/ai_settings.py +290 -0
- sqlseed_web/api.py +1102 -0
- sqlseed_web/app.py +26 -0
- sqlseed_web/managed_worker.py +184 -0
- sqlseed_web/operation_errors.py +42 -0
- sqlseed_web/plugin_environment.py +231 -0
- sqlseed_web/plugin_management.py +322 -0
- sqlseed_web/plugin_process.py +131 -0
- sqlseed_web/runtime_lifecycle.py +130 -0
- sqlseed_web/runtime_session.py +97 -0
- sqlseed_web/settings_environment.py +368 -0
- sqlseed_web/sqlite_target.py +86 -0
- sqlseed_web/state.py +348 -0
- sqlseed_web/static/AGENTS.md +180 -0
- sqlseed_web/static/ai.css +57 -0
- sqlseed_web/static/configs.css +50 -0
- sqlseed_web/static/date-picker.css +230 -0
- sqlseed_web/static/disclosure.css +149 -0
- sqlseed_web/static/graph-clarity.css +103 -0
- sqlseed_web/static/index.html +29 -0
- sqlseed_web/static/js/api.js +228 -0
- sqlseed_web/static/js/app.js +97 -0
- sqlseed_web/static/js/dropdown.js +432 -0
- sqlseed_web/static/js/filepicker.js +203 -0
- sqlseed_web/static/js/genform.js +1066 -0
- sqlseed_web/static/js/labels.js +195 -0
- sqlseed_web/static/js/pages/browse.js +209 -0
- sqlseed_web/static/js/pages/configs.js +424 -0
- sqlseed_web/static/js/pages/connect.js +332 -0
- sqlseed_web/static/js/pages/heal.js +395 -0
- sqlseed_web/static/js/pages/meta.js +110 -0
- sqlseed_web/static/js/pages/runs.js +293 -0
- sqlseed_web/static/js/pages/settings.js +942 -0
- sqlseed_web/static/js/pages/wizard.js +751 -0
- sqlseed_web/static/js/pages/workbench.js +3123 -0
- sqlseed_web/static/js/tree.js +126 -0
- sqlseed_web/static/js/workbench/ai-eligibility.js +33 -0
- sqlseed_web/static/js/workbench/ai-handoff.js +31 -0
- sqlseed_web/static/js/workbench/ai-stream.js +116 -0
- sqlseed_web/static/js/workbench/ai.js +888 -0
- sqlseed_web/static/js/workbench/connection.js +508 -0
- sqlseed_web/static/js/workbench/date-picker.js +445 -0
- sqlseed_web/static/js/workbench/dependency-view.js +119 -0
- sqlseed_web/static/js/workbench/editor.js +1236 -0
- sqlseed_web/static/js/workbench/focus.js +11 -0
- sqlseed_web/static/js/workbench/graph-layout.js +332 -0
- sqlseed_web/static/js/workbench/graph.js +970 -0
- sqlseed_web/static/js/workbench/guidance.js +29 -0
- sqlseed_web/static/js/workbench/model.js +124 -0
- sqlseed_web/static/js/workbench/plugin-management.js +512 -0
- sqlseed_web/static/js/workbench/preview-scroll-layout.js +94 -0
- sqlseed_web/static/js/workbench/preview.js +572 -0
- sqlseed_web/static/js/workbench/provider-guide.js +33 -0
- sqlseed_web/static/js/workbench/recovery.js +28 -0
- sqlseed_web/static/js/workbench/scroll-lock.js +26 -0
- sqlseed_web/static/js/workbench/session.js +174 -0
- sqlseed_web/static/js/workbench/table-data.js +186 -0
- sqlseed_web/static/js/workbench/ui.js +262 -0
- sqlseed_web/static/navigation.css +92 -0
- sqlseed_web/static/preview.css +29 -0
- sqlseed_web/static/runs.css +53 -0
- sqlseed_web/static/scrollbars.css +42 -0
- sqlseed_web/static/settings.css +108 -0
- sqlseed_web/static/style.css +3382 -0
- sqlseed_web/static/table-data.css +27 -0
- sqlseed_web/static/workbench.css +509 -0
- sqlseed_web/supervised_plugins.py +173 -0
- sqlseed_web/supervisor.py +238 -0
- sqlseed_web/workbench.py +381 -0
- sqlseed_web/workbench_ai.py +887 -0
- sqlseed_web/workbench_ai_relations.py +285 -0
- sqlseed_web/workbench_ai_stream.py +172 -0
- sqlseed_web/workbench_data.py +163 -0
- sqlseed_web/workbench_execution.py +199 -0
- sqlseed_web/workbench_runtime.py +1218 -0
- sqlseed_web/workbench_schema.py +277 -0
- sqlseed_web/workbench_store.py +458 -0
- sqlseed_web/worker_control.py +192 -0
- sqlseed_web-0.2.4.dist-info/METADATA +105 -0
- sqlseed_web-0.2.4.dist-info/RECORD +87 -0
- sqlseed_web-0.2.4.dist-info/WHEEL +4 -0
- sqlseed_web-0.2.4.dist-info/entry_points.txt +2 -0
- sqlseed_web-0.2.4.dist-info/licenses/LICENSE +679 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Read real schema and signature-derived generator metadata for the workbench.
|
|
2
|
+
|
|
3
|
+
Only tables in the adapter's default namespace are executable. References to
|
|
4
|
+
other namespaces or missing tables remain visible as read-only graph nodes.
|
|
5
|
+
Generator metadata describes parameter shapes; it is not a complete validator
|
|
6
|
+
for generator-specific rules such as nonempty choices or date boundaries.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import inspect
|
|
13
|
+
import json
|
|
14
|
+
from dataclasses import asdict
|
|
15
|
+
from datetime import date, datetime, time
|
|
16
|
+
from types import UnionType
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Literal, Union, get_args, get_origin, get_type_hints
|
|
18
|
+
|
|
19
|
+
from fastapi.encoders import jsonable_encoder
|
|
20
|
+
from sqlalchemy.engine import URL, make_url
|
|
21
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
22
|
+
from sqlseed.database.sqlalchemy_adapter import SQLAlchemyAdapter
|
|
23
|
+
from sqlseed.generators._dispatch import GeneratorDispatchMixin
|
|
24
|
+
from sqlseed.generators.base_provider import BaseProvider
|
|
25
|
+
|
|
26
|
+
from sqlseed_web.sqlite_target import sqlite_target
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from sqlalchemy.engine.reflection import Inspector
|
|
30
|
+
|
|
31
|
+
from sqlseed_web.state import Connection
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _digest(value: Any) -> str:
|
|
35
|
+
encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
|
|
36
|
+
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _target_identity(conn: Connection) -> tuple[str, str]:
|
|
40
|
+
"""Normalize physical targets and omit userinfo and query values from labels."""
|
|
41
|
+
if (sqlite := sqlite_target(conn.target, conn.conn_id)) is not None:
|
|
42
|
+
return _digest([sqlite.kind, sqlite.value]), sqlite.label
|
|
43
|
+
url = make_url(conn.target)
|
|
44
|
+
dialect = url.get_backend_name()
|
|
45
|
+
host = (url.host or "").lower()
|
|
46
|
+
port = url.port or (5432 if dialect == "postgresql" else None)
|
|
47
|
+
# Query options can affect namespace/routing, so keep them in the opaque
|
|
48
|
+
# hash, excluding authentication. Never expose query values in the label.
|
|
49
|
+
credential_keys = {"user", "username", "password", "passfile", "token", "access_token", "api_key", "secret"}
|
|
50
|
+
options = sorted((key, value) for key, value in url.query.items() if key.lower() not in credential_keys)
|
|
51
|
+
identity = [dialect, host, port, url.database, options]
|
|
52
|
+
label = URL.create(dialect, host=host, port=port, database=url.database).render_as_string()
|
|
53
|
+
return _digest(identity), label
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _refresh_inspector(conn: Connection, adapter: SQLAlchemyAdapter) -> Inspector:
|
|
57
|
+
"""Refresh both metadata caches on the existing, serialized connection.
|
|
58
|
+
|
|
59
|
+
Core exposes the adapter publicly but has no public reflection refresh API.
|
|
60
|
+
Private cache access is confined here until such an API exists.
|
|
61
|
+
SchemaInferrer delegates directly to the adapter and has no separate cache.
|
|
62
|
+
"""
|
|
63
|
+
inspector = adapter._get_inspector()
|
|
64
|
+
inspector.clear_cache()
|
|
65
|
+
adapter._table_cache.clear()
|
|
66
|
+
conn.orchestrator._relation.clear_cache()
|
|
67
|
+
return inspector
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _foreign_keys(inspector: Inspector, table: str, columns: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
71
|
+
"""Preserve each Inspector constraint as one grouped foreign key."""
|
|
72
|
+
nullability = {column["name"]: column["nullable"] for column in columns}
|
|
73
|
+
keys: list[dict[str, Any]] = []
|
|
74
|
+
for reflected in inspector.get_foreign_keys(table):
|
|
75
|
+
source_columns = list(reflected.get("constrained_columns", []))
|
|
76
|
+
key = {
|
|
77
|
+
"columns": source_columns,
|
|
78
|
+
"ref_table": reflected.get("referred_table", ""),
|
|
79
|
+
"ref_schema": reflected.get("referred_schema"),
|
|
80
|
+
"ref_columns": list(reflected.get("referred_columns", [])),
|
|
81
|
+
# Under MATCH SIMPLE, one nullable member can disable a composite
|
|
82
|
+
# reference. Keep the group rather than inventing individual FKs.
|
|
83
|
+
"nullable": any(nullability.get(column, True) for column in source_columns),
|
|
84
|
+
}
|
|
85
|
+
key["id"] = "fk_" + _digest([table, reflected.get("name"), key, len(keys)])[:20]
|
|
86
|
+
keys.append(key)
|
|
87
|
+
return keys
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _sqlite_rowid_alias(conn: Connection, name: str, primary_key: list[str]) -> str | None:
|
|
91
|
+
"""Fallback for older core metadata without explicit rowid-alias facts.
|
|
92
|
+
|
|
93
|
+
SQLite stores ordinary, descending-inline and WITHOUT ROWID primary keys
|
|
94
|
+
in an index with origin ``pk``. A sole, exactly INTEGER rowid alias has no
|
|
95
|
+
such index, including the table-level ``PRIMARY KEY(id DESC)`` exception.
|
|
96
|
+
Using PRAGMA metadata avoids misreading keywords in defaults or comments.
|
|
97
|
+
The core still honors explicit generator rules for implicit rowid aliases.
|
|
98
|
+
"""
|
|
99
|
+
if len(primary_key) != 1:
|
|
100
|
+
return None
|
|
101
|
+
rows = conn.orchestrator.query(
|
|
102
|
+
"SELECT name FROM pragma_table_info(?) WHERE pk = 1 AND upper(type) = 'INTEGER' "
|
|
103
|
+
"AND NOT EXISTS (SELECT 1 FROM pragma_index_list(?) WHERE origin = 'pk')",
|
|
104
|
+
(name, name),
|
|
105
|
+
)
|
|
106
|
+
return str(rows[0]["name"]) if rows else None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _table_schema(conn: Connection, adapter: SQLAlchemyAdapter, inspector: Inspector, name: str) -> dict[str, Any]:
|
|
110
|
+
columns = [asdict(column) for column in adapter.get_column_info(name)]
|
|
111
|
+
primary_key = adapter.get_primary_keys(name)
|
|
112
|
+
needs_rowid_fallback = any(column.get("is_rowid_alias") is None for column in columns)
|
|
113
|
+
rowid_alias = (
|
|
114
|
+
_sqlite_rowid_alias(conn, name, primary_key)
|
|
115
|
+
if adapter.dialect.name == "sqlite" and needs_rowid_fallback
|
|
116
|
+
else None
|
|
117
|
+
)
|
|
118
|
+
for column in columns:
|
|
119
|
+
if column.get("is_rowid_alias") is None:
|
|
120
|
+
column["is_rowid_alias"] = column["name"] == rowid_alias
|
|
121
|
+
unique: dict[tuple[str, ...], dict[str, Any]] = {}
|
|
122
|
+
indexes = adapter.get_index_info(name)
|
|
123
|
+
for constraint in (*adapter.get_unique_constraints(name), *indexes):
|
|
124
|
+
if constraint.unique and constraint.columns and not getattr(constraint, "is_partial", False):
|
|
125
|
+
unique.setdefault(constraint.columns, {"name": constraint.name, "columns": list(constraint.columns)})
|
|
126
|
+
table: dict[str, Any] = {
|
|
127
|
+
"name": name,
|
|
128
|
+
"columns": columns,
|
|
129
|
+
"primary_key": primary_key,
|
|
130
|
+
"unique_constraints": sorted(unique.values(), key=lambda item: (item["columns"], item["name"])),
|
|
131
|
+
"checks": sorted(
|
|
132
|
+
[{"name": check.name, "expression": check.expression} for check in adapter.get_check_constraints(name)],
|
|
133
|
+
key=lambda item: (item["name"], item["expression"]),
|
|
134
|
+
),
|
|
135
|
+
"foreign_keys": _foreign_keys(inspector, name, columns),
|
|
136
|
+
"row_count": adapter.get_row_count(name),
|
|
137
|
+
"mapping": {},
|
|
138
|
+
}
|
|
139
|
+
# Preserve conditional facts in the schema hash without turning their
|
|
140
|
+
# predicates into unconditional generation rules or parsing them in Web.
|
|
141
|
+
conditional = [
|
|
142
|
+
{"name": index.name, "columns": list(index.columns), "unique": index.unique, "predicate": index.predicate}
|
|
143
|
+
for index in indexes
|
|
144
|
+
if getattr(index, "is_partial", False)
|
|
145
|
+
]
|
|
146
|
+
if conditional:
|
|
147
|
+
table["conditional_indexes"] = sorted(conditional, key=lambda item: item["name"])
|
|
148
|
+
try:
|
|
149
|
+
table["mapping"] = {
|
|
150
|
+
column: jsonable_encoder(asdict(spec))
|
|
151
|
+
for column, spec in conn.orchestrator.get_column_mapping(name).items()
|
|
152
|
+
}
|
|
153
|
+
except (RuntimeError, ValueError, KeyError, SQLAlchemyError) as exc:
|
|
154
|
+
# Reflection must remain useful if the mapping resolver cannot handle
|
|
155
|
+
# one table; exception text can contain credentials or sampled values.
|
|
156
|
+
table["mapping_error"] = f"Generator mapping unavailable ({type(exc).__name__})."
|
|
157
|
+
return table
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def inspect_connection(conn: Connection) -> dict[str, Any]:
|
|
161
|
+
"""Inspect the current default namespace after refreshing cached metadata.
|
|
162
|
+
|
|
163
|
+
The caller must hold the registry's connection operation lock so metadata
|
|
164
|
+
refresh cannot race a fill, preview, or connection disposal.
|
|
165
|
+
"""
|
|
166
|
+
conn.orchestrator.get_table_names() # Public API ensures lazy connection.
|
|
167
|
+
adapter = conn.orchestrator.database_adapter
|
|
168
|
+
if not isinstance(adapter, SQLAlchemyAdapter):
|
|
169
|
+
# An unsupported runtime adapter follows the existing RuntimeError API contract.
|
|
170
|
+
raise RuntimeError("The workbench requires a SQLAlchemyAdapter connection.") # noqa: TRY004
|
|
171
|
+
inspector = _refresh_inspector(conn, adapter)
|
|
172
|
+
table_names = sorted(adapter.get_table_names())
|
|
173
|
+
tables = [_table_schema(conn, adapter, inspector, name) for name in table_names]
|
|
174
|
+
default_schema = inspector.default_schema_name
|
|
175
|
+
nodes = {name: {"id": name, "name": name, "readonly": False} for name in table_names}
|
|
176
|
+
edges: list[dict[str, Any]] = []
|
|
177
|
+
for table in tables:
|
|
178
|
+
for key in table["foreign_keys"]:
|
|
179
|
+
ref_schema = key["ref_schema"]
|
|
180
|
+
local_reference = ref_schema in (None, default_schema) and key["ref_table"] in nodes
|
|
181
|
+
source = key["ref_table"] if local_reference or not ref_schema else f"{ref_schema}.{key['ref_table']}"
|
|
182
|
+
if source not in nodes:
|
|
183
|
+
nodes[source] = {
|
|
184
|
+
"id": source,
|
|
185
|
+
"name": key["ref_table"],
|
|
186
|
+
"schema": ref_schema,
|
|
187
|
+
"readonly": True,
|
|
188
|
+
}
|
|
189
|
+
edges.append(
|
|
190
|
+
{
|
|
191
|
+
"id": key["id"],
|
|
192
|
+
"source": source,
|
|
193
|
+
"target": table["name"],
|
|
194
|
+
"sourceColumns": key["ref_columns"],
|
|
195
|
+
"targetColumns": key["columns"],
|
|
196
|
+
"nullable": key["nullable"],
|
|
197
|
+
}
|
|
198
|
+
)
|
|
199
|
+
structural_tables = [
|
|
200
|
+
{key: value for key, value in table.items() if key not in {"row_count", "mapping", "mapping_error"}}
|
|
201
|
+
for table in tables
|
|
202
|
+
]
|
|
203
|
+
target_key, target_label = _target_identity(conn)
|
|
204
|
+
return {
|
|
205
|
+
"schema_hash": _digest([adapter.dialect.name, default_schema, structural_tables]),
|
|
206
|
+
"target_key": target_key,
|
|
207
|
+
"target_label": target_label,
|
|
208
|
+
"dialect": adapter.dialect.name,
|
|
209
|
+
"provider": conn.provider,
|
|
210
|
+
"locale": conn.locale,
|
|
211
|
+
"tables": tables,
|
|
212
|
+
"nodes": list(nodes.values()),
|
|
213
|
+
"edges": edges,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _annotation_type(annotation: Any) -> str:
|
|
218
|
+
"""Map annotations to UI input shapes without claiming semantic validation."""
|
|
219
|
+
origin = get_origin(annotation)
|
|
220
|
+
if origin in (Union, UnionType):
|
|
221
|
+
types = {_annotation_type(item) for item in get_args(annotation) if item is not type(None)}
|
|
222
|
+
return next(iter(types)) if len(types) == 1 else "json"
|
|
223
|
+
if origin is Literal:
|
|
224
|
+
types = {_annotation_type(type(item)) for item in get_args(annotation)}
|
|
225
|
+
return next(iter(types)) if len(types) == 1 else "json"
|
|
226
|
+
if origin in (list, tuple, set):
|
|
227
|
+
return "array"
|
|
228
|
+
if origin is dict:
|
|
229
|
+
return "object"
|
|
230
|
+
return {
|
|
231
|
+
str: "string",
|
|
232
|
+
int: "integer",
|
|
233
|
+
float: "number",
|
|
234
|
+
bool: "boolean",
|
|
235
|
+
bytes: "bytes",
|
|
236
|
+
date: "date",
|
|
237
|
+
datetime: "datetime",
|
|
238
|
+
time: "time",
|
|
239
|
+
type(None): "null",
|
|
240
|
+
}.get(annotation, "json")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def generator_catalog() -> dict[str, Any]:
|
|
244
|
+
"""Describe actual dispatch methods; skip/FK remain separate column modes."""
|
|
245
|
+
names = sorted(GeneratorDispatchMixin.GENERATOR_MAP)
|
|
246
|
+
params_by_name: dict[str, list[str]] = {}
|
|
247
|
+
entries: list[dict[str, Any]] = []
|
|
248
|
+
for name in names:
|
|
249
|
+
method = getattr(BaseProvider, GeneratorDispatchMixin.GENERATOR_MAP[name])
|
|
250
|
+
signature = inspect.signature(method)
|
|
251
|
+
annotations = get_type_hints(method)
|
|
252
|
+
params: list[dict[str, Any]] = []
|
|
253
|
+
for parameter in signature.parameters.values():
|
|
254
|
+
if parameter.name == "self" or parameter.kind in (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD):
|
|
255
|
+
continue
|
|
256
|
+
annotation = annotations.get(parameter.name, Any)
|
|
257
|
+
required = parameter.default is inspect.Parameter.empty
|
|
258
|
+
param = {
|
|
259
|
+
"name": parameter.name,
|
|
260
|
+
"type": _annotation_type(annotation),
|
|
261
|
+
"required": required,
|
|
262
|
+
"default": None if required else jsonable_encoder(parameter.default),
|
|
263
|
+
}
|
|
264
|
+
if get_origin(annotation) is Literal:
|
|
265
|
+
param["choices"] = list(get_args(annotation))
|
|
266
|
+
params.append(param)
|
|
267
|
+
params_by_name[name] = [parameter["name"] for parameter in params]
|
|
268
|
+
entries.append(
|
|
269
|
+
{
|
|
270
|
+
"id": name,
|
|
271
|
+
"label": name.replace("_", " "),
|
|
272
|
+
"params": params,
|
|
273
|
+
"output_type": _annotation_type(annotations.get("return", Any)),
|
|
274
|
+
"description": (inspect.getdoc(method) or "").split("\n\n", 1)[0].replace("\n", " "),
|
|
275
|
+
}
|
|
276
|
+
)
|
|
277
|
+
return {"names": names, "params": params_by_name, "entries": entries}
|