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.
Files changed (87) hide show
  1. sqlseed_web/AGENTS.md +106 -0
  2. sqlseed_web/__init__.py +24 -0
  3. sqlseed_web/__main__.py +8 -0
  4. sqlseed_web/_application.py +205 -0
  5. sqlseed_web/ai_settings.py +290 -0
  6. sqlseed_web/api.py +1102 -0
  7. sqlseed_web/app.py +26 -0
  8. sqlseed_web/managed_worker.py +184 -0
  9. sqlseed_web/operation_errors.py +42 -0
  10. sqlseed_web/plugin_environment.py +231 -0
  11. sqlseed_web/plugin_management.py +322 -0
  12. sqlseed_web/plugin_process.py +131 -0
  13. sqlseed_web/runtime_lifecycle.py +130 -0
  14. sqlseed_web/runtime_session.py +97 -0
  15. sqlseed_web/settings_environment.py +368 -0
  16. sqlseed_web/sqlite_target.py +86 -0
  17. sqlseed_web/state.py +348 -0
  18. sqlseed_web/static/AGENTS.md +180 -0
  19. sqlseed_web/static/ai.css +57 -0
  20. sqlseed_web/static/configs.css +50 -0
  21. sqlseed_web/static/date-picker.css +230 -0
  22. sqlseed_web/static/disclosure.css +149 -0
  23. sqlseed_web/static/graph-clarity.css +103 -0
  24. sqlseed_web/static/index.html +29 -0
  25. sqlseed_web/static/js/api.js +228 -0
  26. sqlseed_web/static/js/app.js +97 -0
  27. sqlseed_web/static/js/dropdown.js +432 -0
  28. sqlseed_web/static/js/filepicker.js +203 -0
  29. sqlseed_web/static/js/genform.js +1066 -0
  30. sqlseed_web/static/js/labels.js +195 -0
  31. sqlseed_web/static/js/pages/browse.js +209 -0
  32. sqlseed_web/static/js/pages/configs.js +424 -0
  33. sqlseed_web/static/js/pages/connect.js +332 -0
  34. sqlseed_web/static/js/pages/heal.js +395 -0
  35. sqlseed_web/static/js/pages/meta.js +110 -0
  36. sqlseed_web/static/js/pages/runs.js +293 -0
  37. sqlseed_web/static/js/pages/settings.js +942 -0
  38. sqlseed_web/static/js/pages/wizard.js +751 -0
  39. sqlseed_web/static/js/pages/workbench.js +3123 -0
  40. sqlseed_web/static/js/tree.js +126 -0
  41. sqlseed_web/static/js/workbench/ai-eligibility.js +33 -0
  42. sqlseed_web/static/js/workbench/ai-handoff.js +31 -0
  43. sqlseed_web/static/js/workbench/ai-stream.js +116 -0
  44. sqlseed_web/static/js/workbench/ai.js +888 -0
  45. sqlseed_web/static/js/workbench/connection.js +508 -0
  46. sqlseed_web/static/js/workbench/date-picker.js +445 -0
  47. sqlseed_web/static/js/workbench/dependency-view.js +119 -0
  48. sqlseed_web/static/js/workbench/editor.js +1236 -0
  49. sqlseed_web/static/js/workbench/focus.js +11 -0
  50. sqlseed_web/static/js/workbench/graph-layout.js +332 -0
  51. sqlseed_web/static/js/workbench/graph.js +970 -0
  52. sqlseed_web/static/js/workbench/guidance.js +29 -0
  53. sqlseed_web/static/js/workbench/model.js +124 -0
  54. sqlseed_web/static/js/workbench/plugin-management.js +512 -0
  55. sqlseed_web/static/js/workbench/preview-scroll-layout.js +94 -0
  56. sqlseed_web/static/js/workbench/preview.js +572 -0
  57. sqlseed_web/static/js/workbench/provider-guide.js +33 -0
  58. sqlseed_web/static/js/workbench/recovery.js +28 -0
  59. sqlseed_web/static/js/workbench/scroll-lock.js +26 -0
  60. sqlseed_web/static/js/workbench/session.js +174 -0
  61. sqlseed_web/static/js/workbench/table-data.js +186 -0
  62. sqlseed_web/static/js/workbench/ui.js +262 -0
  63. sqlseed_web/static/navigation.css +92 -0
  64. sqlseed_web/static/preview.css +29 -0
  65. sqlseed_web/static/runs.css +53 -0
  66. sqlseed_web/static/scrollbars.css +42 -0
  67. sqlseed_web/static/settings.css +108 -0
  68. sqlseed_web/static/style.css +3382 -0
  69. sqlseed_web/static/table-data.css +27 -0
  70. sqlseed_web/static/workbench.css +509 -0
  71. sqlseed_web/supervised_plugins.py +173 -0
  72. sqlseed_web/supervisor.py +238 -0
  73. sqlseed_web/workbench.py +381 -0
  74. sqlseed_web/workbench_ai.py +887 -0
  75. sqlseed_web/workbench_ai_relations.py +285 -0
  76. sqlseed_web/workbench_ai_stream.py +172 -0
  77. sqlseed_web/workbench_data.py +163 -0
  78. sqlseed_web/workbench_execution.py +199 -0
  79. sqlseed_web/workbench_runtime.py +1218 -0
  80. sqlseed_web/workbench_schema.py +277 -0
  81. sqlseed_web/workbench_store.py +458 -0
  82. sqlseed_web/worker_control.py +192 -0
  83. sqlseed_web-0.2.4.dist-info/METADATA +105 -0
  84. sqlseed_web-0.2.4.dist-info/RECORD +87 -0
  85. sqlseed_web-0.2.4.dist-info/WHEEL +4 -0
  86. sqlseed_web-0.2.4.dist-info/entry_points.txt +2 -0
  87. 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}