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.
@@ -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/*
@@ -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,5 @@
1
+ from varro.dashboard.models import Metric, output
2
+ from varro.dashboard.server import build_app
3
+ from varro.dashboard.snapshot import take_snapshot
4
+
5
+ __all__ = ["Metric", "output", "build_app", "take_snapshot"]
@@ -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]