queryview 0.0.2__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 (54) hide show
  1. queryview/__init__.py +0 -0
  2. queryview/conftest.py +86 -0
  3. queryview/connect.py +428 -0
  4. queryview/dashboard_queries.py +75 -0
  5. queryview/dashboards.py +156 -0
  6. queryview/drivers/__init__.py +10 -0
  7. queryview/drivers/base.py +165 -0
  8. queryview/drivers/clickhouse.py +138 -0
  9. queryview/drivers/duckdb.py +153 -0
  10. queryview/drivers/postgres.py +166 -0
  11. queryview/drivers/test_base.py +49 -0
  12. queryview/drivers/test_clickhouse.py +53 -0
  13. queryview/drivers/test_contract.py +77 -0
  14. queryview/drivers/test_duckdb.py +80 -0
  15. queryview/drivers/test_postgres.py +80 -0
  16. queryview/gitsync.py +374 -0
  17. queryview/main.py +740 -0
  18. queryview/mcp_server.py +294 -0
  19. queryview/migrations/env.py +39 -0
  20. queryview/migrations/script.py.mako +29 -0
  21. queryview/migrations/versions/9a536b7c0328_initial_schema.py +89 -0
  22. queryview/migrations/versions/a1b2c3d4e5f6_connection_config_blob.py +59 -0
  23. queryview/migrations/versions/b2c3d4e5f6a7_predefined_presentation.py +32 -0
  24. queryview/migrations/versions/c7d8e9f0a1b2_workspaces.py +98 -0
  25. queryview/queries.py +159 -0
  26. queryview/remote.py +141 -0
  27. queryview/static/assets/index-CvnC_D68.js +47 -0
  28. queryview/static/assets/index-Qe7bhycG.css +2 -0
  29. queryview/static/favicon.svg +1 -0
  30. queryview/static/index.html +14 -0
  31. queryview/test_api_db.py +51 -0
  32. queryview/test_api_export_import.py +85 -0
  33. queryview/test_api_gitsync.py +83 -0
  34. queryview/test_api_workspaces.py +44 -0
  35. queryview/test_connect_flow.py +123 -0
  36. queryview/test_connect_store.py +34 -0
  37. queryview/test_dashboards.py +216 -0
  38. queryview/test_gitsync.py +346 -0
  39. queryview/test_main.py +18 -0
  40. queryview/test_mcp_gitsync.py +72 -0
  41. queryview/test_migrations.py +99 -0
  42. queryview/test_queries.py +170 -0
  43. queryview/test_remote.py +260 -0
  44. queryview/test_validation.py +87 -0
  45. queryview/test_workspaces.py +109 -0
  46. queryview/test_yamlio.py +198 -0
  47. queryview/validation.py +111 -0
  48. queryview/workspaces.py +167 -0
  49. queryview/yamlio.py +245 -0
  50. queryview-0.0.2.dist-info/METADATA +183 -0
  51. queryview-0.0.2.dist-info/RECORD +54 -0
  52. queryview-0.0.2.dist-info/WHEEL +4 -0
  53. queryview-0.0.2.dist-info/entry_points.txt +3 -0
  54. queryview-0.0.2.dist-info/licenses/LICENSE +21 -0
queryview/gitsync.py ADDED
@@ -0,0 +1,374 @@
1
+ """Git sync: per-entity backup/restore of predefined queries and dashboards to
2
+ each workspace's git remote. Operations take a resolved workspaces.WorkspaceRec;
3
+ every workspace has its own clone and lock. Versions are git commits — store
4
+ makes one commit per entity, restore reads objects at a ref (git show) and
5
+ upserts the DB row; HEAD never moves. Docs: docs/gitsync.md, docs/workspace.md."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import os
11
+ import shutil
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import yaml
16
+
17
+ from .workspaces import WorkspaceRec
18
+ from .yamlio import YamlIOError, dashboard_from_data, dump_yaml, query_from_data, query_to_data, slug
19
+
20
+
21
+ class GitSyncError(Exception):
22
+ """Git-sync failure carrying an HTTP-ish status for the API layer:
23
+ 409 unconfigured, 404 entity/ref not found, 502 git or parse failure."""
24
+
25
+ def __init__(self, message: str, status: int = 502):
26
+ super().__init__(message)
27
+ self.status = status
28
+
29
+
30
+ # --- Serialization ---------------------------------------------------------
31
+ # The entity <-> mapping codec (and the literal-block dumper and slug) lives
32
+ # in yamlio.py, shared with YAML export/import; this section only owns the
33
+ # repo file layout: query files carry no `type` (the path does), a dashboard
34
+ # splits into meta.yaml / dashboard.html / queries.yaml.
35
+
36
+
37
+ def query_relpath(conn_type: str, name: str) -> str:
38
+ return f"queries/{slug(conn_type)}/{slug(name)}.yaml"
39
+
40
+
41
+ def dashboard_reldir(name: str) -> str:
42
+ return f"dashboards/{slug(name)}"
43
+
44
+
45
+ def query_to_yaml(row: dict[str, Any]) -> str:
46
+ """One predefined-query row (as returned by list_predefined_queries) as
47
+ YAML. cell_view stays a verbatim string; order_by/fields are stored in the
48
+ DB as JSON text and exported as parsed YAML values. None keys are omitted."""
49
+ return dump_yaml(query_to_data(row))
50
+
51
+
52
+ def query_from_yaml(text: str) -> dict[str, Any]:
53
+ """Inverse of query_to_yaml, back to the DB row shape (order_by/fields as
54
+ JSON text or None)."""
55
+ try:
56
+ data = yaml.safe_load(text)
57
+ except yaml.YAMLError as e:
58
+ raise GitSyncError(f"malformed query file: {e}") from e
59
+ try:
60
+ return query_from_data(data)
61
+ except YamlIOError as e:
62
+ raise GitSyncError(str(e)) from e
63
+
64
+
65
+ def dashboard_to_files(d: dict[str, Any]) -> dict[str, str]:
66
+ """A dashboard (as returned by get_dashboard) as its three repo files."""
67
+ return {
68
+ "meta.yaml": dump_yaml({"name": d["name"], "connection": d["connection"]}),
69
+ "dashboard.html": d["html"],
70
+ "queries.yaml": dump_yaml(d["queries"] or {}),
71
+ }
72
+
73
+
74
+ def dashboard_from_files(files: dict[str, str]) -> dict[str, Any]:
75
+ """Inverse of dashboard_to_files."""
76
+ try:
77
+ meta = yaml.safe_load(files.get("meta.yaml") or "")
78
+ queries = yaml.safe_load(files.get("queries.yaml") or "")
79
+ except yaml.YAMLError as e:
80
+ raise GitSyncError(f"malformed dashboard file: {e}") from e
81
+ data = {**(meta if isinstance(meta, dict) else {}), "html": files.get("dashboard.html", ""), "queries": queries}
82
+ try:
83
+ return dashboard_from_data(data, require_html=False)
84
+ except YamlIOError as e:
85
+ raise GitSyncError(str(e)) from e
86
+
87
+
88
+ # --- Configuration ---------------------------------------------------------
89
+ # Runtime config lives on the workspace row (GIT_SYNC_REMOTE/GIT_SYNC_BRANCH
90
+ # are read once, by the workspaces migration, to seed the default workspace).
91
+
92
+
93
+ def _require_remote(ws: WorkspaceRec) -> str:
94
+ if not ws.remote:
95
+ raise GitSyncError(f"workspace {ws.name!r} has no git remote configured", status=409)
96
+ return ws.remote
97
+
98
+
99
+ def _workdir(ws: WorkspaceRec) -> Path:
100
+ """This workspace's clone: {base}/{workspace id}. Keyed by id so renaming
101
+ a workspace never orphans its clone. GIT_SYNC_DIR overrides the base."""
102
+ env = os.environ.get("GIT_SYNC_DIR")
103
+ if env:
104
+ return Path(env) / str(ws.id)
105
+ from .connect import _db_path
106
+
107
+ return Path(f"{_db_path()}.gitsync") / str(ws.id)
108
+
109
+
110
+ def configured(ws: WorkspaceRec) -> bool:
111
+ return bool(ws.remote)
112
+
113
+
114
+ # --- Git plumbing ----------------------------------------------------------
115
+
116
+ # One git operation at a time per workspace; each workdir is shared mutable
117
+ # state, but different workspaces' clones are independent, so their syncs
118
+ # don't serialize each other. The lock is per (event loop, workspace):
119
+ # asyncio.Lock binds to the loop that first acquires it, and tests run each
120
+ # operation under a fresh asyncio.run loop; production has a single loop.
121
+ _locks: dict[tuple[int, int], asyncio.Lock] = {}
122
+
123
+
124
+ def _lock(ws: WorkspaceRec) -> asyncio.Lock:
125
+ key = (id(asyncio.get_running_loop()), ws.id)
126
+ lock = _locks.get(key)
127
+ if lock is None:
128
+ lock = _locks[key] = asyncio.Lock()
129
+ return lock
130
+
131
+
132
+ async def _git(*args: str, cwd: Path | None = None) -> str:
133
+ proc = await asyncio.create_subprocess_exec(
134
+ "git",
135
+ *args,
136
+ cwd=str(cwd) if cwd else None,
137
+ stdout=asyncio.subprocess.PIPE,
138
+ stderr=asyncio.subprocess.PIPE,
139
+ )
140
+ out, err = await proc.communicate()
141
+ if proc.returncode != 0:
142
+ tail = err.decode("utf-8", "replace").strip()[-500:]
143
+ raise GitSyncError(f"git {args[0]} failed: {tail}")
144
+ return out.decode("utf-8", "replace")
145
+
146
+
147
+ async def _ensure_repo(ws: WorkspaceRec) -> Path:
148
+ """The workspace's sync clone, cloning (or, for an empty remote, init +
149
+ remote add) on first use. Idempotent."""
150
+ remote, branch, wd = _require_remote(ws), ws.branch, _workdir(ws)
151
+ if (wd / ".git").exists():
152
+ return wd
153
+ wd.parent.mkdir(parents=True, exist_ok=True)
154
+ try:
155
+ await _git("clone", "--branch", branch, remote, str(wd))
156
+ except GitSyncError as clone_err:
157
+ # Clone can fail either because the branch genuinely doesn't exist yet
158
+ # on an empty remote (the only case we should paper over with a local
159
+ # init) or because the remote itself is unreachable/misconfigured. Ask
160
+ # the remote directly to tell the two apart.
161
+ try:
162
+ heads = await _git("ls-remote", "--heads", remote, branch)
163
+ except GitSyncError as probe_err:
164
+ raise GitSyncError(f"git remote unreachable: {probe_err}") from probe_err
165
+ if heads.strip():
166
+ raise clone_err
167
+ # Empty remote (branch doesn't exist yet): start locally, attach remote.
168
+ if wd.exists():
169
+ shutil.rmtree(wd) # clean up any partial clone before init
170
+ await _git("init", "-b", branch, str(wd))
171
+ await _git("remote", "add", "origin", remote, cwd=wd)
172
+ await _git("config", "user.name", "queryview", cwd=wd)
173
+ await _git("config", "user.email", "queryview@localhost", cwd=wd)
174
+ return wd
175
+
176
+
177
+ async def _origin_head(wd: Path, ws: WorkspaceRec) -> str | None:
178
+ """Fetch, then the remote branch ref if it exists (None on an empty remote).
179
+ Network/auth failures raise."""
180
+ await _git("fetch", "origin", cwd=wd)
181
+ ref = f"origin/{ws.branch}"
182
+ try:
183
+ await _git("rev-parse", "--verify", ref, cwd=wd)
184
+ except GitSyncError:
185
+ return None
186
+ return ref
187
+
188
+
189
+ # --- Entities --------------------------------------------------------------
190
+
191
+
192
+ def _check_kind(kind: str, conn_type: str | None) -> None:
193
+ """Validate kind/conn_type here so every caller (REST, MCP, future ones)
194
+ inherits it instead of each layer re-implementing the check."""
195
+ if kind not in ("query", "dashboard"):
196
+ raise GitSyncError(f"unknown kind {kind!r} (expected 'query' or 'dashboard')", status=400)
197
+ if kind == "query" and not conn_type:
198
+ raise GitSyncError("conn_type is required for queries", status=400)
199
+
200
+
201
+ def entity_relpath(kind: str, name: str, conn_type: str | None) -> str:
202
+ return query_relpath(conn_type or "", name) if kind == "query" else dashboard_reldir(name)
203
+
204
+
205
+ async def _load_entity(ws: WorkspaceRec, kind: str, name: str, conn_type: str | None) -> dict[str, Any]:
206
+ if kind == "query":
207
+ from .queries import get_predefined_query
208
+
209
+ row = await get_predefined_query(conn_type or "", name, ws.id)
210
+ if row is None:
211
+ raise GitSyncError(f"query {name!r} not found", status=404)
212
+ return row
213
+ from .dashboards import get_dashboard
214
+
215
+ d = await get_dashboard(name, ws.id)
216
+ if d is None:
217
+ raise GitSyncError(f"dashboard {name!r} not found", status=404)
218
+ return d
219
+
220
+
221
+ # --- Operations ------------------------------------------------------------
222
+
223
+
224
+ async def store(
225
+ ws: WorkspaceRec,
226
+ kind: str,
227
+ name: str,
228
+ conn_type: str | None = None,
229
+ message: str | None = None,
230
+ ) -> dict[str, Any]:
231
+ """Export one entity's saved DB state into the clone, commit, push.
232
+ The workdir is reset to the remote head first — exports are deterministic
233
+ from the DB and each commit touches one entity, so this is always safe and
234
+ avoids push rejections."""
235
+ _check_kind(kind, conn_type)
236
+ _require_remote(ws) # unconfigured -> 409 before any DB/entity lookup
237
+ entity = await _load_entity(ws, kind, name, conn_type)
238
+ async with _lock(ws):
239
+ wd = await _ensure_repo(ws)
240
+ head = await _origin_head(wd, ws)
241
+ if head:
242
+ await _git("reset", "--hard", head, cwd=wd)
243
+ relpath = entity_relpath(kind, name, conn_type)
244
+ if kind == "query":
245
+ path = wd / relpath
246
+ path.parent.mkdir(parents=True, exist_ok=True)
247
+ path.write_text(query_to_yaml(entity), encoding="utf-8")
248
+ else:
249
+ ddir = wd / relpath
250
+ if ddir.exists():
251
+ shutil.rmtree(ddir)
252
+ ddir.mkdir(parents=True, exist_ok=True)
253
+ for fname, content in dashboard_to_files(entity).items():
254
+ (ddir / fname).write_text(content, encoding="utf-8")
255
+ await _git("add", "-A", "--", relpath, cwd=wd)
256
+ if not (await _git("status", "--porcelain", "--", relpath, cwd=wd)).strip():
257
+ return {"committed": False, "sha": None, "message": "no changes"}
258
+ label = f"{conn_type}/{name}" if kind == "query" else name
259
+ await _git("commit", "-m", message or f"store {kind} {label}", cwd=wd)
260
+ await _git("push", "origin", ws.branch, cwd=wd)
261
+ sha = (await _git("rev-parse", "HEAD", cwd=wd)).strip()
262
+ return {"committed": True, "sha": sha, "message": "stored"}
263
+
264
+
265
+ async def history(
266
+ ws: WorkspaceRec,
267
+ kind: str,
268
+ name: str,
269
+ conn_type: str | None = None,
270
+ before: str | None = None,
271
+ limit: int = 10,
272
+ ) -> dict[str, Any]:
273
+ """Commits touching the entity's path, newest first. `before=<sha>` pages
274
+ strictly older commits. Reads objects only — never touches the working tree."""
275
+ _check_kind(kind, conn_type)
276
+ _require_remote(ws)
277
+ relpath = entity_relpath(kind, name, conn_type)
278
+ async with _lock(ws):
279
+ wd = await _ensure_repo(ws)
280
+ head = await _origin_head(wd, ws)
281
+ if head is None:
282
+ return {"revisions": [], "has_more": False}
283
+ start = head
284
+ if before:
285
+ try:
286
+ await _git("rev-parse", "--verify", "--quiet", f"{before}^{{commit}}", cwd=wd)
287
+ except GitSyncError as e:
288
+ raise GitSyncError(f"unknown revision {before!r}", status=404) from e
289
+ try:
290
+ await _git("rev-parse", "--verify", "--quiet", f"{before}^", cwd=wd)
291
+ except GitSyncError:
292
+ # `before` is the oldest commit: <sha>^ doesn't resolve.
293
+ return {"revisions": [], "has_more": False}
294
+ start = f"{before}^"
295
+ out = await _git(
296
+ "log",
297
+ f"--max-count={limit + 1}",
298
+ "--format=%H%x1f%ct%x1f%s",
299
+ start,
300
+ "--",
301
+ relpath,
302
+ cwd=wd,
303
+ )
304
+ revisions = []
305
+ for line in out.splitlines():
306
+ sha, ct, subject = line.split("\x1f", 2)
307
+ revisions.append({"sha": sha, "date": int(ct) * 1000, "message": subject})
308
+ return {"revisions": revisions[:limit], "has_more": len(revisions) > limit}
309
+
310
+
311
+ async def restore(
312
+ ws: WorkspaceRec,
313
+ kind: str,
314
+ name: str,
315
+ conn_type: str | None = None,
316
+ ref: str | None = None,
317
+ ) -> dict[str, Any]:
318
+ """Overwrite the local DB row with the entity's content at `ref` (default:
319
+ the remote branch head). Reads via `git show` — HEAD never moves, history
320
+ is never rewritten. Parses fully before writing, so the DB row is either
321
+ untouched or fully replaced."""
322
+ _check_kind(kind, conn_type)
323
+ _require_remote(ws)
324
+ relpath = entity_relpath(kind, name, conn_type)
325
+ async with _lock(ws):
326
+ wd = await _ensure_repo(ws)
327
+ head = await _origin_head(wd, ws)
328
+ resolved = ref if ref and ref != "HEAD" else head
329
+ if resolved is None:
330
+ raise GitSyncError(f"{kind} {name!r} not found in git", status=404)
331
+
332
+ async def _show(path: str) -> str:
333
+ return await _git("show", f"{resolved}:{path}", cwd=wd)
334
+
335
+ if kind == "query":
336
+ try:
337
+ text = await _show(relpath)
338
+ except GitSyncError:
339
+ raise GitSyncError(f"query {name!r} not found at {resolved}", status=404) from None
340
+ data = query_from_yaml(text)
341
+ else:
342
+ files: dict[str, str] = {}
343
+ for fname in ("meta.yaml", "dashboard.html", "queries.yaml"):
344
+ try:
345
+ files[fname] = await _show(f"{relpath}/{fname}")
346
+ except GitSyncError:
347
+ if fname == "meta.yaml":
348
+ raise GitSyncError(f"dashboard {name!r} not found at {resolved}", status=404) from None
349
+ data = dashboard_from_files(files)
350
+
351
+ # DB upsert happens outside the git lock — it doesn't touch the workdir.
352
+ if kind == "query":
353
+ from .queries import save_predefined_query
354
+
355
+ await save_predefined_query(
356
+ data["query_name"],
357
+ conn_type or "",
358
+ data["query"],
359
+ data["cell_view"],
360
+ data["order_by"],
361
+ data["fields"],
362
+ workspace_id=ws.id,
363
+ )
364
+ else:
365
+ from .dashboards import upsert_dashboard
366
+
367
+ await upsert_dashboard(
368
+ data["name"],
369
+ data["connection"],
370
+ data["html"],
371
+ data["queries"],
372
+ workspace_id=ws.id,
373
+ )
374
+ return {"restored": True, "sha": resolved}