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.
- queryview/__init__.py +0 -0
- queryview/conftest.py +86 -0
- queryview/connect.py +428 -0
- queryview/dashboard_queries.py +75 -0
- queryview/dashboards.py +156 -0
- queryview/drivers/__init__.py +10 -0
- queryview/drivers/base.py +165 -0
- queryview/drivers/clickhouse.py +138 -0
- queryview/drivers/duckdb.py +153 -0
- queryview/drivers/postgres.py +166 -0
- queryview/drivers/test_base.py +49 -0
- queryview/drivers/test_clickhouse.py +53 -0
- queryview/drivers/test_contract.py +77 -0
- queryview/drivers/test_duckdb.py +80 -0
- queryview/drivers/test_postgres.py +80 -0
- queryview/gitsync.py +374 -0
- queryview/main.py +740 -0
- queryview/mcp_server.py +294 -0
- queryview/migrations/env.py +39 -0
- queryview/migrations/script.py.mako +29 -0
- queryview/migrations/versions/9a536b7c0328_initial_schema.py +89 -0
- queryview/migrations/versions/a1b2c3d4e5f6_connection_config_blob.py +59 -0
- queryview/migrations/versions/b2c3d4e5f6a7_predefined_presentation.py +32 -0
- queryview/migrations/versions/c7d8e9f0a1b2_workspaces.py +98 -0
- queryview/queries.py +159 -0
- queryview/remote.py +141 -0
- queryview/static/assets/index-CvnC_D68.js +47 -0
- queryview/static/assets/index-Qe7bhycG.css +2 -0
- queryview/static/favicon.svg +1 -0
- queryview/static/index.html +14 -0
- queryview/test_api_db.py +51 -0
- queryview/test_api_export_import.py +85 -0
- queryview/test_api_gitsync.py +83 -0
- queryview/test_api_workspaces.py +44 -0
- queryview/test_connect_flow.py +123 -0
- queryview/test_connect_store.py +34 -0
- queryview/test_dashboards.py +216 -0
- queryview/test_gitsync.py +346 -0
- queryview/test_main.py +18 -0
- queryview/test_mcp_gitsync.py +72 -0
- queryview/test_migrations.py +99 -0
- queryview/test_queries.py +170 -0
- queryview/test_remote.py +260 -0
- queryview/test_validation.py +87 -0
- queryview/test_workspaces.py +109 -0
- queryview/test_yamlio.py +198 -0
- queryview/validation.py +111 -0
- queryview/workspaces.py +167 -0
- queryview/yamlio.py +245 -0
- queryview-0.0.2.dist-info/METADATA +183 -0
- queryview-0.0.2.dist-info/RECORD +54 -0
- queryview-0.0.2.dist-info/WHEEL +4 -0
- queryview-0.0.2.dist-info/entry_points.txt +3 -0
- queryview-0.0.2.dist-info/licenses/LICENSE +21 -0
queryview/yamlio.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""YAML export/import of predefined queries and dashboards — one entity at a
|
|
2
|
+
time or a whole workspace as a single bundle. Every exported document is
|
|
3
|
+
self-describing via a top-level `kind` (query | dashboard | workspace), so
|
|
4
|
+
import dispatches on file content, never on the filename. Import validates the
|
|
5
|
+
whole document before writing anything, then upserts (same overwrite semantics
|
|
6
|
+
as gitsync restore). This module also owns the entity <-> plain-mapping codec
|
|
7
|
+
and the YAML dumper that gitsync's per-entity repo files build on.
|
|
8
|
+
Docs: docs/export-import.md."""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from .validation import cell_view_error, presentation_error
|
|
18
|
+
from .workspaces import WorkspaceRec
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class YamlIOError(Exception):
|
|
22
|
+
"""Export/import failure carrying an HTTP-ish status for the API layer:
|
|
23
|
+
400 malformed document, 404 entity not found."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str, status: int = 400):
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.status = status
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# --- Dumper (also used by gitsync's per-entity files) -----------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _repr_str(dumper: yaml.SafeDumper, data: str):
|
|
34
|
+
# Multiline strings (SQL, HTML, cell_view) as literal blocks for readable
|
|
35
|
+
# files and diffs.
|
|
36
|
+
style = "|" if "\n" in data else None
|
|
37
|
+
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style=style)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _Dumper(yaml.SafeDumper):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_Dumper.add_representer(str, _repr_str)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def dump_yaml(data: Any) -> str:
|
|
48
|
+
return yaml.dump(data, Dumper=_Dumper, sort_keys=False, allow_unicode=True)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_SAFE = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._ -")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def slug(name: str) -> str:
|
|
55
|
+
"""Filesystem-safe name: percent-encode (UTF-8) anything outside
|
|
56
|
+
[A-Za-z0-9._ -] plus a leading dot. Used for export download filenames and
|
|
57
|
+
gitsync repo paths; the canonical name lives inside the YAML — readers
|
|
58
|
+
trust the file content, never the path."""
|
|
59
|
+
out: list[str] = []
|
|
60
|
+
for i, ch in enumerate(name):
|
|
61
|
+
if ch in _SAFE and not (ch == "." and i == 0):
|
|
62
|
+
out.append(ch)
|
|
63
|
+
else:
|
|
64
|
+
out.append("".join(f"%{b:02X}" for b in ch.encode("utf-8")))
|
|
65
|
+
return "".join(out)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# --- Entity <-> mapping codec (shared with gitsync) -------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def query_to_data(row: dict[str, Any]) -> dict[str, Any]:
|
|
72
|
+
"""One predefined-query row (list_predefined_queries shape: order_by/fields
|
|
73
|
+
as JSON text) as its exported mapping. None/empty keys are omitted."""
|
|
74
|
+
data: dict[str, Any] = {"query_name": row["query_name"], "query": row["query"]}
|
|
75
|
+
if row.get("cell_view"):
|
|
76
|
+
data["cell_view"] = row["cell_view"]
|
|
77
|
+
if row.get("order_by"):
|
|
78
|
+
data["order_by"] = json.loads(row["order_by"])
|
|
79
|
+
if row.get("fields"):
|
|
80
|
+
data["fields"] = json.loads(row["fields"])
|
|
81
|
+
return data
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _require_str(data: dict[str, Any], key: str, where: str) -> str:
|
|
85
|
+
value = data.get(key)
|
|
86
|
+
if not isinstance(value, str) or not value.strip():
|
|
87
|
+
raise YamlIOError(f"malformed {where}: {key} must be a non-empty string")
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def query_from_data(data: Any, where: str = "query file") -> dict[str, Any]:
|
|
92
|
+
"""Validate a query mapping back to the DB row shape save_predefined_query
|
|
93
|
+
takes (order_by/fields re-serialized to JSON text or None)."""
|
|
94
|
+
if not isinstance(data, dict):
|
|
95
|
+
raise YamlIOError(f"malformed {where}: expected a mapping")
|
|
96
|
+
query_name = _require_str(data, "query_name", where)
|
|
97
|
+
query = _require_str(data, "query", where)
|
|
98
|
+
cell_view = data.get("cell_view")
|
|
99
|
+
ob, fl = data.get("order_by"), data.get("fields")
|
|
100
|
+
perr = cell_view_error(cell_view) or presentation_error(ob, fl)
|
|
101
|
+
if perr is not None:
|
|
102
|
+
raise YamlIOError(f"malformed {where}: {perr}")
|
|
103
|
+
return {
|
|
104
|
+
"query_name": query_name,
|
|
105
|
+
"query": query,
|
|
106
|
+
"cell_view": cell_view or None,
|
|
107
|
+
"order_by": json.dumps(ob) if ob else None,
|
|
108
|
+
"fields": json.dumps(fl) if fl else None,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def dashboard_to_data(d: dict[str, Any]) -> dict[str, Any]:
|
|
113
|
+
"""One dashboard (get_dashboard shape) as its exported mapping. Keys are
|
|
114
|
+
listed explicitly to pin the document format independently of what the
|
|
115
|
+
store accessors happen to return."""
|
|
116
|
+
return {
|
|
117
|
+
"name": d["name"],
|
|
118
|
+
"connection": d["connection"],
|
|
119
|
+
"html": d["html"],
|
|
120
|
+
"queries": d["queries"] or {},
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def dashboard_from_data(data: Any, where: str = "dashboard file", require_html: bool = True) -> dict[str, Any]:
|
|
125
|
+
"""Validate a dashboard mapping back to the upsert_dashboard shape.
|
|
126
|
+
gitsync passes require_html=False: its repo format stores the HTML as a
|
|
127
|
+
separate file that may legitimately be absent/empty."""
|
|
128
|
+
if not isinstance(data, dict):
|
|
129
|
+
raise YamlIOError(f"malformed {where}: expected a mapping")
|
|
130
|
+
name = _require_str(data, "name", where)
|
|
131
|
+
connection = _require_str(data, "connection", where)
|
|
132
|
+
html = _require_str(data, "html", where) if require_html else str(data.get("html") or "")
|
|
133
|
+
queries = data.get("queries") or {}
|
|
134
|
+
if not isinstance(queries, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in queries.items()):
|
|
135
|
+
raise YamlIOError(f"malformed {where}: queries must be a {{name: SQL}} map")
|
|
136
|
+
return {"name": name, "connection": connection, "html": html, "queries": queries}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# --- Import documents -------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _parse_query_entry(data: Any, where: str = "query document") -> dict[str, Any]:
|
|
143
|
+
"""A query import entry: the shared codec row plus the `type` key export
|
|
144
|
+
documents carry (gitsync files get it from the repo path instead)."""
|
|
145
|
+
if not isinstance(data, dict):
|
|
146
|
+
raise YamlIOError(f"malformed {where}: expected a mapping")
|
|
147
|
+
return {"type": _require_str(data, "type", where), **query_from_data(data, where)}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def parse_document(text: str) -> tuple[str, Any]:
|
|
151
|
+
"""Parse + validate an import document. Returns (kind, payload): for
|
|
152
|
+
kind 'query'/'dashboard' the payload is one parsed entry; for 'workspace'
|
|
153
|
+
a {'queries': [...], 'dashboards': [...]} pair of parsed entries."""
|
|
154
|
+
try:
|
|
155
|
+
data = yaml.safe_load(text)
|
|
156
|
+
except yaml.YAMLError as e:
|
|
157
|
+
raise YamlIOError(f"malformed YAML: {e}") from e
|
|
158
|
+
if not isinstance(data, dict):
|
|
159
|
+
raise YamlIOError("malformed document: expected a YAML mapping")
|
|
160
|
+
kind = data.get("kind")
|
|
161
|
+
if kind == "query":
|
|
162
|
+
return "query", _parse_query_entry(data)
|
|
163
|
+
if kind == "dashboard":
|
|
164
|
+
return "dashboard", dashboard_from_data(data, "dashboard document")
|
|
165
|
+
if kind == "workspace":
|
|
166
|
+
raw_queries = data.get("queries") or []
|
|
167
|
+
raw_dashboards = data.get("dashboards") or []
|
|
168
|
+
if not isinstance(raw_queries, list) or not isinstance(raw_dashboards, list):
|
|
169
|
+
raise YamlIOError("malformed workspace document: queries and dashboards must be lists")
|
|
170
|
+
return "workspace", {
|
|
171
|
+
"queries": [_parse_query_entry(q, f"workspace queries[{i}]") for i, q in enumerate(raw_queries)],
|
|
172
|
+
"dashboards": [dashboard_from_data(d, f"workspace dashboards[{i}]") for i, d in enumerate(raw_dashboards)],
|
|
173
|
+
}
|
|
174
|
+
raise YamlIOError("malformed document: kind must be 'query', 'dashboard' or 'workspace'")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# --- Services ---------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def export(kind: str, name: str, conn_type: str, ws: WorkspaceRec) -> tuple[str, str]:
|
|
181
|
+
"""Export dispatch shared by every caller (REST today, MCP/CLI tomorrow):
|
|
182
|
+
validates kind/name/conn_type, loads the document, and owns the
|
|
183
|
+
`{slug}.{kind}.yaml` download-filename convention. Returns (filename, text)."""
|
|
184
|
+
if kind not in ("query", "dashboard", "workspace") or (kind != "workspace" and not name):
|
|
185
|
+
raise YamlIOError("kind ('query'|'dashboard'|'workspace') and (for entities) name are required")
|
|
186
|
+
if kind == "query":
|
|
187
|
+
if not conn_type:
|
|
188
|
+
raise YamlIOError("conn_type is required for queries")
|
|
189
|
+
return f"{slug(name)}.query.yaml", await export_query(conn_type, name, ws.id)
|
|
190
|
+
if kind == "dashboard":
|
|
191
|
+
return f"{slug(name)}.dashboard.yaml", await export_dashboard(name, ws.id)
|
|
192
|
+
return f"{slug(ws.name)}.workspace.yaml", await export_workspace(ws.id)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def export_query(conn_type: str, name: str, workspace_id: int) -> str:
|
|
196
|
+
from .queries import get_predefined_query
|
|
197
|
+
|
|
198
|
+
row = await get_predefined_query(conn_type, name, workspace_id)
|
|
199
|
+
if row is None:
|
|
200
|
+
raise YamlIOError(f"query {name!r} not found", status=404)
|
|
201
|
+
return dump_yaml({"kind": "query", "type": conn_type, **query_to_data(row)})
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def export_dashboard(name: str, workspace_id: int) -> str:
|
|
205
|
+
from .dashboards import get_dashboard
|
|
206
|
+
|
|
207
|
+
d = await get_dashboard(name, workspace_id)
|
|
208
|
+
if d is None:
|
|
209
|
+
raise YamlIOError(f"dashboard {name!r} not found", status=404)
|
|
210
|
+
return dump_yaml({"kind": "dashboard", **dashboard_to_data(d)})
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def export_workspace(workspace_id: int) -> str:
|
|
214
|
+
"""The whole workspace (every predefined query and dashboard) as one
|
|
215
|
+
bundle. Empty workspaces export a valid, importable empty bundle."""
|
|
216
|
+
from .dashboards import list_dashboards_full
|
|
217
|
+
from .queries import list_all_predefined_queries
|
|
218
|
+
|
|
219
|
+
queries = await list_all_predefined_queries(workspace_id)
|
|
220
|
+
dashboards = await list_dashboards_full(workspace_id)
|
|
221
|
+
return dump_yaml(
|
|
222
|
+
{
|
|
223
|
+
"kind": "workspace",
|
|
224
|
+
"queries": [{"type": r["type"], **query_to_data(r)} for r in queries],
|
|
225
|
+
"dashboards": [dashboard_to_data(d) for d in dashboards],
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
async def import_text(text: str, workspace_id: int) -> dict[str, Any]:
|
|
231
|
+
"""Import a document into a workspace, upserting by name. The whole
|
|
232
|
+
document is validated before the first write, so a malformed file changes
|
|
233
|
+
nothing; each entity family then writes in a single transaction. Returns
|
|
234
|
+
{'kind', 'queries', 'dashboards'} counts."""
|
|
235
|
+
from .dashboards import upsert_dashboards
|
|
236
|
+
from .queries import save_predefined_queries
|
|
237
|
+
|
|
238
|
+
kind, payload = parse_document(text)
|
|
239
|
+
queries = payload["queries"] if kind == "workspace" else [payload] if kind == "query" else []
|
|
240
|
+
dashboards = payload["dashboards"] if kind == "workspace" else [payload] if kind == "dashboard" else []
|
|
241
|
+
if queries:
|
|
242
|
+
await save_predefined_queries(queries, workspace_id=workspace_id)
|
|
243
|
+
if dashboards:
|
|
244
|
+
await upsert_dashboards(dashboards, workspace_id=workspace_id)
|
|
245
|
+
return {"kind": kind, "queries": len(queries), "dashboards": len(dashboards)}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: queryview
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: QueryView: FastAPI + SQLModel backend and React SPA over the ClickHouse HTTP interface
|
|
5
|
+
Project-URL: Homepage, https://github.com/kolodkin/queryview
|
|
6
|
+
Project-URL: Repository, https://github.com/kolodkin/queryview
|
|
7
|
+
Project-URL: Issues, https://github.com/kolodkin/queryview/issues
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: aiosqlite>=0.22.1
|
|
12
|
+
Requires-Dist: alembic>=1.13
|
|
13
|
+
Requires-Dist: asyncpg>=0.29
|
|
14
|
+
Requires-Dist: cryptography>=42
|
|
15
|
+
Requires-Dist: duckdb>=1.0
|
|
16
|
+
Requires-Dist: fastapi>=0.110
|
|
17
|
+
Requires-Dist: httpx>=0.27
|
|
18
|
+
Requires-Dist: mcp<2,>=1.9
|
|
19
|
+
Requires-Dist: pyyaml>=6
|
|
20
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0
|
|
21
|
+
Requires-Dist: sqlmodel>=0.0.16
|
|
22
|
+
Requires-Dist: uvicorn>=0.29
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# QueryView
|
|
26
|
+
|
|
27
|
+
Project skeleton: **Python** backend (**FastAPI + SQLModel**) + **Vite + React + TypeScript** SPA frontend with **Tailwind CSS**, plus **[Playwright](https://playwright.dev)** end-to-end tests.
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
Run the released package — API + bundled SPA on http://localhost:8000:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uvx queryview
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`--port` (or the `PORT` env var) picks the listen port, default 8000:
|
|
38
|
+
`uvx queryview --port 9000`.
|
|
39
|
+
|
|
40
|
+
Or run the container image — every release publishes
|
|
41
|
+
`ghcr.io/kolodkin/queryview` to GHCR for `linux/amd64` and `linux/arm64`, tagged
|
|
42
|
+
`vX.Y.Z` and (for non-pre-releases) `latest`:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
docker run -p 8000:8000 ghcr.io/kolodkin/queryview:latest
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
To serve on a different host port, remap it (the container keeps listening on
|
|
49
|
+
8000, which its healthcheck probes): `docker run -p 9000:8000 ...`.
|
|
50
|
+
|
|
51
|
+
State (the SQLite DB and its encryption key) lives in `/home/queryview`; mount a
|
|
52
|
+
volume there to persist it across containers:
|
|
53
|
+
`docker run -p 8000:8000 -v queryview-data:/home/queryview ghcr.io/kolodkin/queryview:latest`.
|
|
54
|
+
|
|
55
|
+
## Layout
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
.
|
|
59
|
+
├── backend/ # Python FastAPI + SQLModel app exposing /api/* (queryview package)
|
|
60
|
+
├── frontend/ # Vite + React + TS + Tailwind v4 SPA (npm workspace)
|
|
61
|
+
├── e2e/ # Playwright (pytest) browser tests
|
|
62
|
+
├── pyproject.toml # Backend deps + console script + e2e `test` group (uv)
|
|
63
|
+
└── package.json # npm workspace root: dev orchestration + frontend build
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Prerequisites
|
|
67
|
+
|
|
68
|
+
- [uv](https://docs.astral.sh/uv/) — runs the Python backend and the Playwright
|
|
69
|
+
(pytest) e2e suite (it manages the Python toolchain and dependencies for you).
|
|
70
|
+
- [Node.js](https://nodejs.org) 20+ (with npm) — runs the root tasks and the
|
|
71
|
+
Vite frontend.
|
|
72
|
+
|
|
73
|
+
npm runs the frontend and the root task scripts; uv handles the backend's and
|
|
74
|
+
e2e suite's Python virtualenv and dependencies.
|
|
75
|
+
|
|
76
|
+
## Install
|
|
77
|
+
|
|
78
|
+
Install the backend's Python dependencies (uv reads the root `pyproject.toml`;
|
|
79
|
+
the package lives in `backend/queryview`):
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
uv sync
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Install the JavaScript dependencies for the frontend workspace:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
npm install
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Install the e2e tooling (the `test` dependency group) and fetch the Playwright
|
|
92
|
+
browser:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
uv sync --group test
|
|
96
|
+
uv run --group test playwright install chromium
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Run dev servers
|
|
100
|
+
|
|
101
|
+
Run backend and frontend together:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
npm run dev
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Or individually:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
npm run backend # uvicorn --reload on http://localhost:8000
|
|
111
|
+
npm run frontend # http://localhost:5173
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The Vite dev server proxies `/api/*` to the FastAPI backend, so the SPA can call the API on the same origin.
|
|
115
|
+
|
|
116
|
+
## Build & preview production
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
npm run build # produces frontend/dist/
|
|
120
|
+
npm run start # SERVE_STATIC=1, FastAPI serves dist/ + /api on :8000
|
|
121
|
+
npm run preview # build && start in one shot
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
In production there is no Vite — the FastAPI backend serves the bundled SPA from `frontend/dist/` and falls back to `index.html` for any unknown non-`/api` path so client-side routing works. Override the dist location with `STATIC_ROOT=/path/to/dist`.
|
|
125
|
+
|
|
126
|
+
## End-to-end tests
|
|
127
|
+
|
|
128
|
+
The e2e suite is [pytest-playwright](https://playwright.dev/python/docs/test-runners),
|
|
129
|
+
installed via the `test` dependency group and run through `uv`.
|
|
130
|
+
|
|
131
|
+
Start the dev servers (`npm run dev`) in one terminal, then in another:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
uv run --group test pytest
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Override the target URL with `BASE_URL=http://localhost:4173 uv run --group test pytest` (e.g. to test a built preview). To run the full suite against a real ClickHouse the way CI does, use `scripts/setup.sh`.
|
|
138
|
+
|
|
139
|
+
## Release to PyPI
|
|
140
|
+
|
|
141
|
+
The **Publish to PyPI** workflow (`.github/workflows/publish.yaml`, manual
|
|
142
|
+
dispatch with a `vX.Y.Z` tag input) builds the SPA into the wheel
|
|
143
|
+
(`queryview/static/`), then gates the release on the installed wheel: an HTTP
|
|
144
|
+
smoke test, the packaged backend test suite (`pytest --pyargs queryview`), and
|
|
145
|
+
the Playwright e2e suite driving the packaged server (skippable via the
|
|
146
|
+
`skip-e2e` input for emergencies). It then publishes
|
|
147
|
+
[`queryview`](https://pypi.org/project/queryview/) via PyPI trusted publishing,
|
|
148
|
+
pushes the tag, and creates the GitHub release. The package version comes
|
|
149
|
+
from the tag (no version bump in `pyproject.toml`).
|
|
150
|
+
|
|
151
|
+
An installed wheel serves the bundled UI by default — see
|
|
152
|
+
[Quick start](#quick-start).
|
|
153
|
+
|
|
154
|
+
## MCP server
|
|
155
|
+
|
|
156
|
+
The backend mounts a FastMCP server (Streamable HTTP) at
|
|
157
|
+
`http://localhost:8000/mcp`. There is nothing extra to start — it runs inside
|
|
158
|
+
the server process (`uvx queryview`, `npm run dev`, ...). Hook up an MCP client,
|
|
159
|
+
e.g.:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
claude mcp add --transport http queryview http://localhost:8000/mcp
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Tools: `run_query` (read-only SQL, rows returned to the agent), `push_query`
|
|
166
|
+
and `push_dashboard` (fill a live browser session), `list_queries` /
|
|
167
|
+
`list_dashboards`, and `git_store` / `git_history` / `git_restore` (workspace
|
|
168
|
+
git backups). The push tools target an **armed** browser session: enable
|
|
169
|
+
"Allow remote control" from the agent icon next to the connection pill and use
|
|
170
|
+
the session id it shows. See [docs/remote.md](docs/remote.md) for the full
|
|
171
|
+
protocol.
|
|
172
|
+
|
|
173
|
+
## API
|
|
174
|
+
|
|
175
|
+
See [docs/api.md](docs/api.md) for the full endpoint reference.
|
|
176
|
+
|
|
177
|
+
The single-page prompt UI is described in [docs/queryview.md](docs/queryview.md);
|
|
178
|
+
connecting (`new <type>` / `connect <name>`), SQLite persistence, and session
|
|
179
|
+
auto-connect are specified in [docs/connect.md](docs/connect.md).
|
|
180
|
+
|
|
181
|
+
Connections are stored in SQLite (`backend/queryview.db`, override with
|
|
182
|
+
`DB_PATH`); the backend writes that file and a local password-encryption key
|
|
183
|
+
(`backend/queryview.db.key`, override with `DB_KEY_PATH`).
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
queryview/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
queryview/conftest.py,sha256=TJpnXRopAQufqXpCIizcC2p-eJML9I70gaji_3MxV_k,2918
|
|
3
|
+
queryview/connect.py,sha256=97r_XtBdLJ_-LqhERlxrepgP-dYYJ4_puF9Mv6S8oPQ,14724
|
|
4
|
+
queryview/dashboard_queries.py,sha256=Q2DbHSjIr8h9p5Y3HMo3oNNrbbfaiGW8colBCSD5Bh8,2770
|
|
5
|
+
queryview/dashboards.py,sha256=az92sJBYy6jBheyd186sqNoPVELRPoLGbmWyl0J_pQg,6075
|
|
6
|
+
queryview/gitsync.py,sha256=UCHNpJPWAFnOat2nGlgq_TPM_g64ZlYcxXAVMgiSp0o,14367
|
|
7
|
+
queryview/main.py,sha256=I_YyNRRAPgiFWqY_VvlolOayByWSRt4JLLoderrbDcI,27701
|
|
8
|
+
queryview/mcp_server.py,sha256=E8DhTrisom7xAXJ3F-qIQzrV-gVG4Qp5Sq_7wuBHpCY,11603
|
|
9
|
+
queryview/queries.py,sha256=isGYoUMShr1B9dXdwiozzqavRXfeFsIj9Mjs1U04b88,6148
|
|
10
|
+
queryview/remote.py,sha256=gdt5O_3x53M0ZP3Z-gnm3zi_Jx-0BbdJHoFhNlT_FBk,5090
|
|
11
|
+
queryview/test_api_db.py,sha256=6QOlyZK9OsrVm0foz7M32S4M2Qv_sdGjNHnjV3v4OC4,1897
|
|
12
|
+
queryview/test_api_export_import.py,sha256=EJ32EBBoN2EXbyV-v1H0E9AumPc2qzX1EU0893dbaJY,3690
|
|
13
|
+
queryview/test_api_gitsync.py,sha256=KtD3yxWQ7UyGV8S260N4Ez86l4oMg3NgQ5hxRrtVIfk,2731
|
|
14
|
+
queryview/test_api_workspaces.py,sha256=yyn6ib6s3B0TnTnLCAd1T1k5ev-kBGtkWtzINfaGpEU,1700
|
|
15
|
+
queryview/test_connect_flow.py,sha256=P37kTaGXDxXOAYXvR5LMbp7k2_XIacP4YcTnnIwpVT0,4454
|
|
16
|
+
queryview/test_connect_store.py,sha256=lxu3Yg5rQlza4HBWsvJ7LNJYo8dB0xD_DwXMNRkoUx4,1065
|
|
17
|
+
queryview/test_dashboards.py,sha256=ib9jJ-ueMF02LR3mvYIigSmdZ5N9wA_01Ww2UljskMo,8075
|
|
18
|
+
queryview/test_gitsync.py,sha256=T5j4AHxMMseIKsMvdsQqWrbKgJp8qTKcq8QoTGmR78s,13053
|
|
19
|
+
queryview/test_main.py,sha256=k5YuxmwfU0zemzbF2ktdCSiLes8vYUu7LPoW8gAiWHY,504
|
|
20
|
+
queryview/test_mcp_gitsync.py,sha256=UU1n0ic9jY-c4jDzlV-IUZO7j9SIVIMfwOGImzQMEL8,2328
|
|
21
|
+
queryview/test_migrations.py,sha256=QVPnQYBkOzLGGEowJxHeUzQVYcYlBwFq348TAzNKxLs,3380
|
|
22
|
+
queryview/test_queries.py,sha256=Mm264HqBzDKZ1A0aRf_ol8aSOA5hAyzSG7nC1Ji10rk,5694
|
|
23
|
+
queryview/test_remote.py,sha256=1p9J2yNdJ94rfk2rHqEZ32T4kJpaYcMEXZVVP_GNDoQ,7980
|
|
24
|
+
queryview/test_validation.py,sha256=HzxpThEQ31sH35-GJZ7xN0tuuWYgjUCXexuJxZXJoSU,3221
|
|
25
|
+
queryview/test_workspaces.py,sha256=uQig4cay59p_-EgHUJdn0OefLH1hAj-Vh21GHnSMt7k,3546
|
|
26
|
+
queryview/test_yamlio.py,sha256=3WxqkB2ACSYXP_Xpss6bD_cdBYR_FbZFgmFmfMFR3Wk,7325
|
|
27
|
+
queryview/validation.py,sha256=CtwKtRpvErdfJ4ruIC6Hlno85g_-11pBwD4JnNbjDOg,5098
|
|
28
|
+
queryview/workspaces.py,sha256=FsXWBT6iM3qC8Uql2MULZDyg2CLYfZzF0G6opjY6LZo,6182
|
|
29
|
+
queryview/yamlio.py,sha256=DyiiWuOWIkCGaMWu54GnPNoDEZ7T6lyCVAgYLL26Gss,10548
|
|
30
|
+
queryview/drivers/__init__.py,sha256=msOh2EgJtGE-Qus1sCLbtin_cXwmNgCFz33o_C0cU3E,359
|
|
31
|
+
queryview/drivers/base.py,sha256=KlL3XIqvbGB_GnDWvuRuMpdXqs0_JUpKuPNhFOEJrQ0,6626
|
|
32
|
+
queryview/drivers/clickhouse.py,sha256=Lw71Ujvl5znVFzLNHlB5-N6f1k4OanwpPZPj4cxNn-w,4938
|
|
33
|
+
queryview/drivers/duckdb.py,sha256=wgGamLKSlwN_egR_3osNgwk6JhKhv2T9BHoY0L3pMZU,5641
|
|
34
|
+
queryview/drivers/postgres.py,sha256=NSy1_eXAR1ui0BMd2P8b1buUKo07jK2MrrI1qFZnoDI,6044
|
|
35
|
+
queryview/drivers/test_base.py,sha256=VfABajT8ej4BDtv3OAoMPuPXCkdTrLAeDCvddni7Ud0,1808
|
|
36
|
+
queryview/drivers/test_clickhouse.py,sha256=PqR5J275VTWRWXRmwyL1NVaFAs6o5t7HZ_0FpWA1oIY,1963
|
|
37
|
+
queryview/drivers/test_contract.py,sha256=m1i6jKt7_pRiCASKA7TgNcrNgImZBMOFRlDwx0ebmJE,2671
|
|
38
|
+
queryview/drivers/test_duckdb.py,sha256=Zh4Grr8lo6QYRxOzL73T8lZXp4RjjGzPpBFwfTJGloU,2565
|
|
39
|
+
queryview/drivers/test_postgres.py,sha256=lnjKqbTdZq0qkZuMDLR_dCHAn2ucekNhag_Ya-YFvSQ,2534
|
|
40
|
+
queryview/migrations/env.py,sha256=BLN0lYLwBT-Gl5nAGQSmlPWdEQ-NQubC8Ap6EXJuuGw,1419
|
|
41
|
+
queryview/migrations/script.py.mako,sha256=I8FVdur13rxBM8AzY-PHgpIpwtuXxbgquD6QHTzSeQI,687
|
|
42
|
+
queryview/migrations/versions/9a536b7c0328_initial_schema.py,sha256=UoF755Xi7usJ1ic8TArFA1FDqVE-1AnQcLC-201_2pk,3888
|
|
43
|
+
queryview/migrations/versions/a1b2c3d4e5f6_connection_config_blob.py,sha256=cqulYLtwC0l3ePGELD13IRJ9IizmfQThcOPv06bGvO8,2393
|
|
44
|
+
queryview/migrations/versions/b2c3d4e5f6a7_predefined_presentation.py,sha256=oIbxmPwoied62M5pPuGCszt2Ou_bn2vyyM3pNVzM9xE,942
|
|
45
|
+
queryview/migrations/versions/c7d8e9f0a1b2_workspaces.py,sha256=XfO6F0ZwbKTT_JUYrAEA33OS31ohFVT2MCufAzbSZN8,3897
|
|
46
|
+
queryview/static/favicon.svg,sha256=zurDhDS-ejtND2i4zYqiua54wmDWNDCHxuCV-AMc5P8,9523
|
|
47
|
+
queryview/static/index.html,sha256=K1ErE6hqmLl6-5Ldg7bigRLI3_ejwSWrvniXPhreNsc,459
|
|
48
|
+
queryview/static/assets/index-CvnC_D68.js,sha256=D7tm3lpnGYyfnGus3dosTWUpdhb9QRg096HPyKCGlbE,328887
|
|
49
|
+
queryview/static/assets/index-Qe7bhycG.css,sha256=AGivYysIrMwIfCTxDIHFi2Okz6fzHkFf7-ErAzlZLF0,27807
|
|
50
|
+
queryview-0.0.2.dist-info/METADATA,sha256=8NHQblmPmPjkCTHqKJyTdXDkrAxbxFaxIWniQpa-89E,6420
|
|
51
|
+
queryview-0.0.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
52
|
+
queryview-0.0.2.dist-info/entry_points.txt,sha256=5Eu59D91AZfkEb76UvcXWk2Grk17Ej-QTIvl-IkGG1s,88
|
|
53
|
+
queryview-0.0.2.dist-info/licenses/LICENSE,sha256=viH3V6L-xOvOuiFosKdQNQ8Xnt6bK0dnYuxhCHmQZu8,1070
|
|
54
|
+
queryview-0.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mark kolodkin
|
|
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.
|