varro-mcp 0.1.0__tar.gz
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.
- varro_mcp-0.1.0/.gitignore +19 -0
- varro_mcp-0.1.0/PKG-INFO +42 -0
- varro_mcp-0.1.0/README.md +11 -0
- varro_mcp-0.1.0/pyproject.toml +55 -0
- varro_mcp-0.1.0/varro/cli.py +25 -0
- varro_mcp-0.1.0/varro/constants.py +9 -0
- varro_mcp-0.1.0/varro/dashboard/__init__.py +5 -0
- varro_mcp-0.1.0/varro/dashboard/components.py +324 -0
- varro_mcp-0.1.0/varro/dashboard/executor.py +58 -0
- varro_mcp-0.1.0/varro/dashboard/filters.py +145 -0
- varro_mcp-0.1.0/varro/dashboard/helpers.py +164 -0
- varro_mcp-0.1.0/varro/dashboard/loader.py +368 -0
- varro_mcp-0.1.0/varro/dashboard/models.py +29 -0
- varro_mcp-0.1.0/varro/dashboard/parser.py +156 -0
- varro_mcp-0.1.0/varro/dashboard/queries.py +140 -0
- varro_mcp-0.1.0/varro/dashboard/server.py +304 -0
- varro_mcp-0.1.0/varro/dashboard/snapshot.py +157 -0
- varro_mcp-0.1.0/varro/dashboard/static/dashboard.css +521 -0
- varro_mcp-0.1.0/varro/dashboard/static/varro_logo.png +0 -0
- varro_mcp-0.1.0/varro/dashboard/static/varro_logo.svg +264 -0
- varro_mcp-0.1.0/varro/dashboard/static/varro_logo_white_bg.png +0 -0
- varro_mcp-0.1.0/varro/dashboard/tables.py +466 -0
- varro_mcp-0.1.0/varro/main.py +307 -0
- varro_mcp-0.1.0/varro/notebook.py +23 -0
- varro_mcp-0.1.0/varro/shell.py +60 -0
- varro_mcp-0.1.0/varro/sql.py +51 -0
- varro_mcp-0.1.0/varro/utils.py +64 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Python-generated files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[oc]
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
wheels/
|
|
7
|
+
*.egg-info
|
|
8
|
+
|
|
9
|
+
# Virtual environments
|
|
10
|
+
.venv
|
|
11
|
+
node_modules/
|
|
12
|
+
.sesskey
|
|
13
|
+
|
|
14
|
+
# macOS metadata
|
|
15
|
+
**/.DS_Store
|
|
16
|
+
# plugin/*
|
|
17
|
+
.DS_Store
|
|
18
|
+
|
|
19
|
+
dasbhoard_design/*
|
varro_mcp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: varro-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SQL, persistent Jupyter, and markdown dashboard tooling for Codex.
|
|
5
|
+
Project-URL: Homepage, https://github.com/josca42/varro_plugin
|
|
6
|
+
Project-URL: Repository, https://github.com/josca42/varro_plugin
|
|
7
|
+
Project-URL: Issues, https://github.com/josca42/varro_plugin/issues
|
|
8
|
+
Author: josca42
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: dashboards,data-analysis,jupyter,mcp,sql
|
|
11
|
+
Requires-Python: >=3.13
|
|
12
|
+
Requires-Dist: geopandas>=1.1.3
|
|
13
|
+
Requires-Dist: ipython>=9.12.0
|
|
14
|
+
Requires-Dist: jinja2>=3.1.0
|
|
15
|
+
Requires-Dist: kaleido>=1.0.0
|
|
16
|
+
Requires-Dist: logfire>=4.32.1
|
|
17
|
+
Requires-Dist: matplotlib>=3.10.8
|
|
18
|
+
Requires-Dist: mcp[cli]>=1.27.0
|
|
19
|
+
Requires-Dist: openpyxl>=3.1.5
|
|
20
|
+
Requires-Dist: pandas>=3.0.2
|
|
21
|
+
Requires-Dist: plotly>=6.7.0
|
|
22
|
+
Requires-Dist: psycopg[binary]>=3.3.4
|
|
23
|
+
Requires-Dist: pyarrow>=18.0.0
|
|
24
|
+
Requires-Dist: pydantic>=2.0.0
|
|
25
|
+
Requires-Dist: python-fasthtml>=0.12.0
|
|
26
|
+
Requires-Dist: scikit-learn>=1.8.0
|
|
27
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
28
|
+
Requires-Dist: tabulate>=0.10.0
|
|
29
|
+
Requires-Dist: uvicorn>=0.30.0
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# Varro
|
|
33
|
+
|
|
34
|
+
SQL, persistent Jupyter, and markdown dashboard tooling for Codex.
|
|
35
|
+
|
|
36
|
+
This package provides the Python runtime for the Varro plugin:
|
|
37
|
+
|
|
38
|
+
- `varro-mcp` starts the MCP server with SQL, Jupyter, and dashboard snapshot tools.
|
|
39
|
+
- `varro` starts the local dashboard HTTP server for a project workspace.
|
|
40
|
+
|
|
41
|
+
The MCP server reads project state from the current working directory by default. Set
|
|
42
|
+
`VARRO_PROJECT_DIR` when launching it from another location.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Varro
|
|
2
|
+
|
|
3
|
+
SQL, persistent Jupyter, and markdown dashboard tooling for Codex.
|
|
4
|
+
|
|
5
|
+
This package provides the Python runtime for the Varro plugin:
|
|
6
|
+
|
|
7
|
+
- `varro-mcp` starts the MCP server with SQL, Jupyter, and dashboard snapshot tools.
|
|
8
|
+
- `varro` starts the local dashboard HTTP server for a project workspace.
|
|
9
|
+
|
|
10
|
+
The MCP server reads project state from the current working directory by default. Set
|
|
11
|
+
`VARRO_PROJECT_DIR` when launching it from another location.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "varro-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "SQL, persistent Jupyter, and markdown dashboard tooling for Codex."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "josca42" },
|
|
10
|
+
]
|
|
11
|
+
keywords = ["mcp", "sql", "jupyter", "dashboards", "data-analysis"]
|
|
12
|
+
dependencies = [
|
|
13
|
+
"ipython>=9.12.0",
|
|
14
|
+
"jinja2>=3.1.0",
|
|
15
|
+
"matplotlib>=3.10.8",
|
|
16
|
+
"mcp[cli]>=1.27.0",
|
|
17
|
+
"pandas>=3.0.2",
|
|
18
|
+
"plotly>=6.7.0",
|
|
19
|
+
"python-fasthtml>=0.12.0",
|
|
20
|
+
"kaleido>=1.0.0",
|
|
21
|
+
"uvicorn>=0.30.0",
|
|
22
|
+
"pydantic>=2.0.0",
|
|
23
|
+
"pyarrow>=18.0.0",
|
|
24
|
+
"logfire>=4.32.1",
|
|
25
|
+
"sqlalchemy>=2.0",
|
|
26
|
+
"psycopg[binary]>=3.3.4",
|
|
27
|
+
"scikit-learn>=1.8.0",
|
|
28
|
+
"geopandas>=1.1.3",
|
|
29
|
+
"tabulate>=0.10.0",
|
|
30
|
+
"openpyxl>=3.1.5",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
varro = "varro.cli:main"
|
|
35
|
+
varro-mcp = "varro.main:main"
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/josca42/varro_plugin"
|
|
39
|
+
Repository = "https://github.com/josca42/varro_plugin"
|
|
40
|
+
Issues = "https://github.com/josca42/varro_plugin/issues"
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["hatchling"]
|
|
44
|
+
build-backend = "hatchling.build"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["varro"]
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.sdist]
|
|
50
|
+
include = [
|
|
51
|
+
"README.md",
|
|
52
|
+
"pyproject.toml",
|
|
53
|
+
"varro/**/*.py",
|
|
54
|
+
"varro/dashboard/static/**/*",
|
|
55
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
parser = argparse.ArgumentParser(
|
|
8
|
+
prog="varro", description="Run the varro dashboard HTTP server."
|
|
9
|
+
)
|
|
10
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
11
|
+
parser.add_argument("--port", type=int, default=5011)
|
|
12
|
+
parser.add_argument("--project-dir", required=True, help="Project folder")
|
|
13
|
+
args = parser.parse_args()
|
|
14
|
+
|
|
15
|
+
os.environ["VARRO_PROJECT_DIR"] = str(Path(args.project_dir).resolve())
|
|
16
|
+
|
|
17
|
+
import uvicorn
|
|
18
|
+
|
|
19
|
+
from varro.dashboard import build_app
|
|
20
|
+
|
|
21
|
+
uvicorn.run(build_app(Path(args.project_dir)), host=args.host, port=args.port)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
main()
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
PROJECT_DIR = Path(os.environ.get("VARRO_PROJECT_DIR", Path.cwd())).resolve()
|
|
5
|
+
DASHBOARDS_DIR = PROJECT_DIR / "dashboards"
|
|
6
|
+
NOTEBOOKS_DIR = PROJECT_DIR / "notebooks"
|
|
7
|
+
DATA_DIR = PROJECT_DIR / "data"
|
|
8
|
+
VARRO_DIR = PROJECT_DIR / ".varro"
|
|
9
|
+
SQL_CONNECTION_FILE = VARRO_DIR / "sql_connection.txt"
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from typing import Any
|
|
5
|
+
from urllib.parse import urlencode
|
|
6
|
+
|
|
7
|
+
from fasthtml.common import (
|
|
8
|
+
A,
|
|
9
|
+
Aside,
|
|
10
|
+
Button,
|
|
11
|
+
Div,
|
|
12
|
+
Form as HtmlForm,
|
|
13
|
+
Img,
|
|
14
|
+
Main,
|
|
15
|
+
NotStr,
|
|
16
|
+
Span,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from varro.dashboard.filters import Filter, SelectFilter
|
|
20
|
+
from varro.dashboard.helpers import FilterInput
|
|
21
|
+
from varro.dashboard.loader import Dashboard, Page
|
|
22
|
+
from varro.dashboard.parser import (
|
|
23
|
+
ASTNode,
|
|
24
|
+
ComponentNode,
|
|
25
|
+
ContainerNode,
|
|
26
|
+
MarkdownNode,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
MARKDOWN_CLASS = "prose"
|
|
30
|
+
|
|
31
|
+
PANEL_ICON = NotStr(
|
|
32
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" '
|
|
33
|
+
'stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">'
|
|
34
|
+
'<rect x="3" y="3" width="18" height="18" rx="2"/>'
|
|
35
|
+
'<line x1="15" y1="3" x2="15" y2="21"/></svg>'
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
TOGGLE_JS = NotStr(
|
|
39
|
+
"""
|
|
40
|
+
<script>
|
|
41
|
+
(function(){
|
|
42
|
+
var t = document.getElementById('toc-toggle');
|
|
43
|
+
if (!t) return;
|
|
44
|
+
var app = document.querySelector('.app');
|
|
45
|
+
|
|
46
|
+
if (localStorage.getItem('toc-collapsed') === '1') {
|
|
47
|
+
app.classList.add('collapsed');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
t.addEventListener('click', function(){
|
|
51
|
+
app.classList.toggle('collapsed');
|
|
52
|
+
localStorage.setItem(
|
|
53
|
+
'toc-collapsed',
|
|
54
|
+
app.classList.contains('collapsed') ? '1' : '0'
|
|
55
|
+
);
|
|
56
|
+
setTimeout(function(){
|
|
57
|
+
window.dispatchEvent(new Event('resize'));
|
|
58
|
+
}, 220);
|
|
59
|
+
});
|
|
60
|
+
})();
|
|
61
|
+
</script>
|
|
62
|
+
"""
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _placeholder(
|
|
67
|
+
dash_name: str, output_type: str, output_name: str, attrs: dict[str, str]
|
|
68
|
+
) -> Any:
|
|
69
|
+
query = {
|
|
70
|
+
f"__{key}": value
|
|
71
|
+
for key, value in attrs.items()
|
|
72
|
+
if key != "name"
|
|
73
|
+
}
|
|
74
|
+
suffix = f"?{urlencode(query)}" if query else ""
|
|
75
|
+
digest = hashlib.sha1(
|
|
76
|
+
repr((output_type, output_name, sorted(query.items()))).encode()
|
|
77
|
+
).hexdigest()[:8]
|
|
78
|
+
return Div(
|
|
79
|
+
Div(Div(cls="loading-spinner"), cls="card loading-card"),
|
|
80
|
+
hx_get=f"/{dash_name}/_/{output_type}/{output_name}{suffix}",
|
|
81
|
+
hx_include="#filters",
|
|
82
|
+
hx_trigger="load, filtersChanged from:body",
|
|
83
|
+
hx_swap="innerHTML",
|
|
84
|
+
id=f"placeholder-{output_type}-{output_name}-{digest}",
|
|
85
|
+
cls="output-slot",
|
|
86
|
+
data_output_name=output_name,
|
|
87
|
+
data_output_type=output_type,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _render_filters(
|
|
92
|
+
filter_defs: list[Filter],
|
|
93
|
+
dash: Dashboard,
|
|
94
|
+
filters: dict[str, Any],
|
|
95
|
+
options: dict[str, list[Any]],
|
|
96
|
+
) -> Any:
|
|
97
|
+
inputs = [Span("Filters", cls="filter-label")]
|
|
98
|
+
for f in filter_defs:
|
|
99
|
+
if isinstance(f, SelectFilter):
|
|
100
|
+
current = filters.get(f.name, f.default)
|
|
101
|
+
opts = options.get(f.name, [])
|
|
102
|
+
inputs.append(FilterInput(f, current, options=opts))
|
|
103
|
+
else:
|
|
104
|
+
inputs.append(FilterInput(f, filters))
|
|
105
|
+
|
|
106
|
+
return HtmlForm(
|
|
107
|
+
*inputs,
|
|
108
|
+
id="filters",
|
|
109
|
+
hx_get=_page_href(dash.name, dash.page) + "/_/filters",
|
|
110
|
+
hx_trigger="change delay:300ms",
|
|
111
|
+
hx_swap="none",
|
|
112
|
+
cls="filters",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _render_tabs(
|
|
117
|
+
tab_nodes: list[ContainerNode],
|
|
118
|
+
dash: Dashboard,
|
|
119
|
+
filters: dict[str, Any],
|
|
120
|
+
options: dict[str, list[Any]],
|
|
121
|
+
) -> Any:
|
|
122
|
+
buttons = []
|
|
123
|
+
contents = []
|
|
124
|
+
for i, tab in enumerate(tab_nodes):
|
|
125
|
+
name = tab.attrs.get("name", f"Tab {i + 1}")
|
|
126
|
+
buttons.append(
|
|
127
|
+
Button(
|
|
128
|
+
name,
|
|
129
|
+
type="button",
|
|
130
|
+
role="tab",
|
|
131
|
+
cls="tab",
|
|
132
|
+
**{
|
|
133
|
+
":class": f"active === {i} && 'tab-active'",
|
|
134
|
+
"@click": f"active = {i}",
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
contents.append(
|
|
139
|
+
Div(
|
|
140
|
+
*render_ast(tab.children, dash, filters, options),
|
|
141
|
+
x_show=f"active === {i}",
|
|
142
|
+
x_cloak=True,
|
|
143
|
+
role="tabpanel",
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
return Div(
|
|
147
|
+
Div(*buttons, role="tablist", cls="tabs-shadcn"),
|
|
148
|
+
*contents,
|
|
149
|
+
x_data="{ active: 0 }",
|
|
150
|
+
cls="tabs-block",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _grid_class(cols: str) -> str:
|
|
155
|
+
return f"grid-{cols}"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def render_ast(
|
|
159
|
+
nodes: list[ASTNode],
|
|
160
|
+
dash: Dashboard,
|
|
161
|
+
filters: dict[str, Any],
|
|
162
|
+
options: dict[str, list[Any]],
|
|
163
|
+
page_header: bool = False,
|
|
164
|
+
) -> list[Any]:
|
|
165
|
+
out: list[Any] = []
|
|
166
|
+
header_pending = page_header
|
|
167
|
+
|
|
168
|
+
for node in nodes:
|
|
169
|
+
if isinstance(node, MarkdownNode):
|
|
170
|
+
if header_pending:
|
|
171
|
+
out.append(_page_header(node, dash))
|
|
172
|
+
header_pending = False
|
|
173
|
+
else:
|
|
174
|
+
out.append(Div(node.content, cls=f"section-header {MARKDOWN_CLASS}"))
|
|
175
|
+
|
|
176
|
+
elif isinstance(node, ComponentNode):
|
|
177
|
+
if node.type in ("fig", "table", "metric"):
|
|
178
|
+
name = node.attrs.get("name", "")
|
|
179
|
+
if name:
|
|
180
|
+
out.append(_placeholder(dash.name, node.type, name, node.attrs))
|
|
181
|
+
|
|
182
|
+
elif isinstance(node, ContainerNode):
|
|
183
|
+
if node.type == "filters":
|
|
184
|
+
filter_defs = [c for c in node.children if isinstance(c, Filter)]
|
|
185
|
+
out.append(_render_filters(filter_defs, dash, filters, options))
|
|
186
|
+
|
|
187
|
+
elif node.type == "grid":
|
|
188
|
+
children = render_ast(node.children, dash, filters, options)
|
|
189
|
+
out.append(Div(*children, cls=_container_grid_class(node)))
|
|
190
|
+
|
|
191
|
+
elif node.type == "tabs":
|
|
192
|
+
tab_nodes = [
|
|
193
|
+
c
|
|
194
|
+
for c in node.children
|
|
195
|
+
if isinstance(c, ContainerNode) and c.type == "tab"
|
|
196
|
+
]
|
|
197
|
+
if tab_nodes:
|
|
198
|
+
out.append(_render_tabs(tab_nodes, dash, filters, options))
|
|
199
|
+
|
|
200
|
+
elif node.type != "tab":
|
|
201
|
+
out.extend(render_ast(node.children, dash, filters, options))
|
|
202
|
+
|
|
203
|
+
return out
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _container_grid_class(node: ContainerNode) -> str:
|
|
207
|
+
if node.children and all(
|
|
208
|
+
isinstance(c, ComponentNode) and c.type == "metric" for c in node.children
|
|
209
|
+
):
|
|
210
|
+
return "kpi-grid"
|
|
211
|
+
return _grid_class(node.attrs.get("cols", "2"))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _page_header(node: MarkdownNode, dash: Dashboard) -> Any:
|
|
215
|
+
return Div(
|
|
216
|
+
_crumbs(dash),
|
|
217
|
+
Div(node.content, cls=MARKDOWN_CLASS),
|
|
218
|
+
cls="page-header",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _crumbs(dash: Dashboard) -> Any:
|
|
223
|
+
parts: list[Any] = [Span("Dashboards")]
|
|
224
|
+
if dash.page.slug:
|
|
225
|
+
parts.extend([Span("/", cls="sep"), A(dash.title, href=f"/{dash.name}")])
|
|
226
|
+
parts.extend([Span("/", cls="sep"), Span(dash.page.title)])
|
|
227
|
+
else:
|
|
228
|
+
parts.extend([Span("/", cls="sep"), Span(dash.title)])
|
|
229
|
+
return Div(*parts, cls="crumbs")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _humanize(slug: str) -> str:
|
|
233
|
+
return slug.replace("-", " ").replace("_", " ").title()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _render_toc(
|
|
237
|
+
dash_name: str,
|
|
238
|
+
current_page: str | None,
|
|
239
|
+
site: list[dict[str, Any]],
|
|
240
|
+
) -> Any:
|
|
241
|
+
items = []
|
|
242
|
+
for entry in site:
|
|
243
|
+
name = entry["name"]
|
|
244
|
+
pages = entry.get("pages", [])
|
|
245
|
+
is_active_dash = name == dash_name
|
|
246
|
+
children: list[Any] = [
|
|
247
|
+
A(Span(_humanize(name)), href=f"/{name}", cls="toc-dash-link")
|
|
248
|
+
]
|
|
249
|
+
if is_active_dash:
|
|
250
|
+
children.append(_toc_pages(name, pages, current_page))
|
|
251
|
+
items.append(
|
|
252
|
+
Div(*children, cls=f"toc-dash{' active' if is_active_dash else ''}")
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
return Aside(
|
|
256
|
+
_render_toc_top(),
|
|
257
|
+
Div(*items, cls="toc-list"),
|
|
258
|
+
cls="toc",
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _render_toc_top() -> Any:
|
|
263
|
+
return Div(
|
|
264
|
+
A(
|
|
265
|
+
Span(
|
|
266
|
+
Img(src="/static/varro_logo.png", alt=""),
|
|
267
|
+
cls="brand-mark",
|
|
268
|
+
),
|
|
269
|
+
Span("VARRO", cls="brand-name"),
|
|
270
|
+
href="/",
|
|
271
|
+
cls="toc-brand",
|
|
272
|
+
),
|
|
273
|
+
Button(
|
|
274
|
+
PANEL_ICON,
|
|
275
|
+
id="toc-toggle",
|
|
276
|
+
cls="toc-toggle",
|
|
277
|
+
aria_label="Toggle sidebar",
|
|
278
|
+
type="button",
|
|
279
|
+
),
|
|
280
|
+
cls="toc-top",
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _toc_pages(name: str, pages: list[str], current_page: str | None) -> Any:
|
|
285
|
+
links = [
|
|
286
|
+
A(
|
|
287
|
+
"Overview",
|
|
288
|
+
href=f"/{name}",
|
|
289
|
+
cls=f"toc-page{' active' if current_page is None else ''}",
|
|
290
|
+
)
|
|
291
|
+
]
|
|
292
|
+
links.extend(
|
|
293
|
+
A(
|
|
294
|
+
_humanize(slug),
|
|
295
|
+
href=f"/{name}/{slug}",
|
|
296
|
+
cls=f"toc-page{' active' if slug == current_page else ''}",
|
|
297
|
+
)
|
|
298
|
+
for slug in pages
|
|
299
|
+
)
|
|
300
|
+
return Div(*links, cls="toc-pages")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def render_shell(
|
|
304
|
+
dash: Dashboard,
|
|
305
|
+
filters: dict[str, Any],
|
|
306
|
+
options: dict[str, list[Any]],
|
|
307
|
+
site: list[dict[str, Any]] | None = None,
|
|
308
|
+
current_page: str | None = None,
|
|
309
|
+
) -> Any:
|
|
310
|
+
content = render_ast(dash.ast, dash, filters, options, page_header=True)
|
|
311
|
+
return Div(
|
|
312
|
+
Main(*content),
|
|
313
|
+
_render_toc(dash.name, current_page, site or _default_site(dash)),
|
|
314
|
+
TOGGLE_JS,
|
|
315
|
+
cls="app",
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _default_site(dash: Dashboard) -> list[dict[str, Any]]:
|
|
320
|
+
return [{"name": dash.name, "pages": [p.slug for p in dash.pages if p.slug]}]
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _page_href(dash_name: str, page: Page) -> str:
|
|
324
|
+
return f"/{dash_name}" if page.slug is None else f"/{dash_name}/{page.slug}"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from sqlalchemy.engine import Engine
|
|
7
|
+
|
|
8
|
+
from varro.dashboard.loader import Dashboard
|
|
9
|
+
from varro.dashboard.queries import execute_query
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def output_query_names(dash: Dashboard, output_name: str) -> set[str]:
|
|
13
|
+
if output_name not in dash.outputs:
|
|
14
|
+
raise KeyError(f"Unknown output {output_name!r} in dashboard {dash.name!r}")
|
|
15
|
+
return {
|
|
16
|
+
name
|
|
17
|
+
for name in inspect.signature(dash.outputs[output_name]).parameters
|
|
18
|
+
if name in dash.queries
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def execute_output(
|
|
23
|
+
dash: Dashboard,
|
|
24
|
+
output_name: str,
|
|
25
|
+
filters: dict[str, Any],
|
|
26
|
+
engine: Engine | None = None,
|
|
27
|
+
) -> Any:
|
|
28
|
+
if output_name not in dash.outputs:
|
|
29
|
+
raise KeyError(f"Unknown output {output_name!r} in dashboard {dash.name!r}")
|
|
30
|
+
|
|
31
|
+
fn = dash.outputs[output_name]
|
|
32
|
+
kwargs: dict[str, Any] = {}
|
|
33
|
+
|
|
34
|
+
for param_name in inspect.signature(fn).parameters:
|
|
35
|
+
if param_name == "filters":
|
|
36
|
+
kwargs[param_name] = filters
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
if param_name in dash.queries:
|
|
40
|
+
if engine is None:
|
|
41
|
+
raise RuntimeError(
|
|
42
|
+
f"Output {output_name!r} requires SQL query {param_name!r}, "
|
|
43
|
+
"but no SQL connection is configured."
|
|
44
|
+
)
|
|
45
|
+
kwargs[param_name] = execute_query(
|
|
46
|
+
dash.queries[param_name],
|
|
47
|
+
filters,
|
|
48
|
+
engine,
|
|
49
|
+
)
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
known_queries = ", ".join(sorted(dash.queries)) or "none"
|
|
53
|
+
raise TypeError(
|
|
54
|
+
f"Unknown parameter {param_name!r} on output {output_name!r}. "
|
|
55
|
+
f"Use 'filters' or one of the dashboard query names: {known_queries}."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return fn(**kwargs)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Literal, Mapping
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, field_validator
|
|
8
|
+
|
|
9
|
+
SelectOption = tuple[str, str]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Filter(BaseModel):
|
|
13
|
+
model_config = ConfigDict(extra="forbid")
|
|
14
|
+
|
|
15
|
+
type: str
|
|
16
|
+
name: str
|
|
17
|
+
label: str | None = None
|
|
18
|
+
|
|
19
|
+
@field_validator("label", mode="before")
|
|
20
|
+
@classmethod
|
|
21
|
+
def _default_label(cls, value: str | None, info) -> str | None:
|
|
22
|
+
if value is None or value == "":
|
|
23
|
+
return info.data.get("name") or None
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
def parse_query_params(self, params: Mapping[str, str]) -> dict[str, Any]:
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
|
|
29
|
+
def url_params(self, values: Mapping[str, Any]) -> dict[str, str]:
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SelectFilter(Filter):
|
|
34
|
+
type: Literal["select"] = "select"
|
|
35
|
+
default: str = "all"
|
|
36
|
+
options_spec: str = ""
|
|
37
|
+
|
|
38
|
+
def parse_query_params(self, params: Mapping[str, str]) -> dict[str, Any]:
|
|
39
|
+
return {self.name: params.get(self.name, self.default)}
|
|
40
|
+
|
|
41
|
+
def url_params(self, values: Mapping[str, Any]) -> dict[str, str]:
|
|
42
|
+
v = values.get(self.name, self.default)
|
|
43
|
+
return {self.name: str(v)} if v != self.default else {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DateRangeFilter(Filter):
|
|
47
|
+
type: Literal["daterange"] = "daterange"
|
|
48
|
+
default_from: str | None = None
|
|
49
|
+
default_to: str | None = None
|
|
50
|
+
|
|
51
|
+
def parse_query_params(self, params: Mapping[str, str]) -> dict[str, Any]:
|
|
52
|
+
fk, tk = f"{self.name}_from", f"{self.name}_to"
|
|
53
|
+
return {
|
|
54
|
+
fk: params.get(fk) or self.default_from,
|
|
55
|
+
tk: params.get(tk) or self.default_to,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def url_params(self, values: Mapping[str, Any]) -> dict[str, str]:
|
|
59
|
+
fk, tk = f"{self.name}_from", f"{self.name}_to"
|
|
60
|
+
out: dict[str, str] = {}
|
|
61
|
+
if (fv := values.get(fk)) and fv != self.default_from:
|
|
62
|
+
out[fk] = str(fv)
|
|
63
|
+
if (tv := values.get(tk)) and tv != self.default_to:
|
|
64
|
+
out[tk] = str(tv)
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class CheckboxFilter(Filter):
|
|
69
|
+
type: Literal["checkbox"] = "checkbox"
|
|
70
|
+
default: bool = False
|
|
71
|
+
|
|
72
|
+
@field_validator("default", mode="before")
|
|
73
|
+
@classmethod
|
|
74
|
+
def _parse_default(cls, v: Any) -> bool:
|
|
75
|
+
if isinstance(v, str):
|
|
76
|
+
return v.lower() == "true"
|
|
77
|
+
return bool(v)
|
|
78
|
+
|
|
79
|
+
def parse_query_params(self, params: Mapping[str, str]) -> dict[str, Any]:
|
|
80
|
+
v = params.get(self.name)
|
|
81
|
+
if v is None:
|
|
82
|
+
return {self.name: self.default}
|
|
83
|
+
return {self.name: v.lower() == "true"}
|
|
84
|
+
|
|
85
|
+
def url_params(self, values: Mapping[str, Any]) -> dict[str, str]:
|
|
86
|
+
v = values.get(self.name, self.default)
|
|
87
|
+
return {self.name: "true" if v else "false"} if v != self.default else {}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def filter_from_component(type_: str, attrs: dict[str, str]) -> Filter | None:
|
|
91
|
+
name = attrs.get("name", "")
|
|
92
|
+
if not name:
|
|
93
|
+
return None
|
|
94
|
+
label = attrs.get("label")
|
|
95
|
+
|
|
96
|
+
if type_ == "filter-select":
|
|
97
|
+
return SelectFilter(
|
|
98
|
+
name=name,
|
|
99
|
+
label=label,
|
|
100
|
+
default=attrs.get("default", "all"),
|
|
101
|
+
options_spec=attrs.get("options", ""),
|
|
102
|
+
)
|
|
103
|
+
if type_ == "filter-date":
|
|
104
|
+
return DateRangeFilter(
|
|
105
|
+
name=name,
|
|
106
|
+
label=label,
|
|
107
|
+
default_from=attrs.get("default_from"),
|
|
108
|
+
default_to=attrs.get("default_to"),
|
|
109
|
+
)
|
|
110
|
+
if type_ == "filter-check":
|
|
111
|
+
return CheckboxFilter(
|
|
112
|
+
name=name,
|
|
113
|
+
label=label,
|
|
114
|
+
default=attrs.get("default", "false"),
|
|
115
|
+
)
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def resolve_select_options(spec: str, root: Path) -> list[SelectOption]:
|
|
120
|
+
spec = spec.strip()
|
|
121
|
+
if not spec:
|
|
122
|
+
return []
|
|
123
|
+
|
|
124
|
+
if spec.startswith("data:"):
|
|
125
|
+
rest = spec[len("data:"):]
|
|
126
|
+
path_part, _, col = rest.rpartition(":")
|
|
127
|
+
if not path_part or not col:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"Bad select options spec {spec!r}: expected data:<path>:<column>"
|
|
130
|
+
)
|
|
131
|
+
file_path = root / "data" / path_part
|
|
132
|
+
suffix = file_path.suffix.lower()
|
|
133
|
+
if suffix == ".csv":
|
|
134
|
+
df = pd.read_csv(file_path)
|
|
135
|
+
elif suffix in (".parquet", ".pq"):
|
|
136
|
+
df = pd.read_parquet(file_path)
|
|
137
|
+
elif suffix == ".json":
|
|
138
|
+
df = pd.read_json(file_path)
|
|
139
|
+
else:
|
|
140
|
+
raise ValueError(f"Unsupported data file type: {suffix}")
|
|
141
|
+
values = sorted(df[col].astype(str).unique().tolist())
|
|
142
|
+
return [("all", "All")] + [(value, value) for value in values]
|
|
143
|
+
|
|
144
|
+
values = [s.strip() for s in spec.split(",") if s.strip()]
|
|
145
|
+
return [(value, "All" if value == "all" else value) for value in values]
|