vgi-python 0.8.0__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.
- vgi/__init__.py +152 -0
- vgi/_duckdb.py +62 -0
- vgi/_storage_profile.py +132 -0
- vgi/_test_fixtures/__init__.py +20 -0
- vgi/_test_fixtures/accumulate/__init__.py +19 -0
- vgi/_test_fixtures/accumulate/worker.py +762 -0
- vgi/_test_fixtures/aggregate/__init__.py +62 -0
- vgi/_test_fixtures/aggregate/_common.py +21 -0
- vgi/_test_fixtures/aggregate/basic.py +232 -0
- vgi/_test_fixtures/aggregate/dynamic.py +409 -0
- vgi/_test_fixtures/aggregate/generic.py +86 -0
- vgi/_test_fixtures/aggregate/listagg.py +71 -0
- vgi/_test_fixtures/aggregate/percentile.py +107 -0
- vgi/_test_fixtures/aggregate/streaming.py +192 -0
- vgi/_test_fixtures/aggregate/varargs.py +75 -0
- vgi/_test_fixtures/aggregate/window.py +380 -0
- vgi/_test_fixtures/attach_options.py +308 -0
- vgi/_test_fixtures/bad_protocol.py +62 -0
- vgi/_test_fixtures/cancellable.py +336 -0
- vgi/_test_fixtures/catalog.py +813 -0
- vgi/_test_fixtures/http_server.py +394 -0
- vgi/_test_fixtures/nest_tensor.py +614 -0
- vgi/_test_fixtures/orchard_catalog.py +47 -0
- vgi/_test_fixtures/projection_repro/__init__.py +6 -0
- vgi/_test_fixtures/projection_repro/worker.py +454 -0
- vgi/_test_fixtures/scalar/__init__.py +116 -0
- vgi/_test_fixtures/scalar/_common.py +69 -0
- vgi/_test_fixtures/scalar/arithmetic.py +321 -0
- vgi/_test_fixtures/scalar/binary.py +120 -0
- vgi/_test_fixtures/scalar/formatting.py +176 -0
- vgi/_test_fixtures/scalar/geo.py +300 -0
- vgi/_test_fixtures/scalar/null_handling.py +107 -0
- vgi/_test_fixtures/scalar/random_demo.py +171 -0
- vgi/_test_fixtures/scalar/settings_secrets.py +102 -0
- vgi/_test_fixtures/scalar/type_info.py +219 -0
- vgi/_test_fixtures/schema_reconcile/__init__.py +29 -0
- vgi/_test_fixtures/schema_reconcile/worker.py +653 -0
- vgi/_test_fixtures/simple_writable.py +793 -0
- vgi/_test_fixtures/table/__init__.py +221 -0
- vgi/_test_fixtures/table/_common.py +162 -0
- vgi/_test_fixtures/table/batch_index.py +283 -0
- vgi/_test_fixtures/table/batch_index_broken.py +200 -0
- vgi/_test_fixtures/table/catalog_scans.py +162 -0
- vgi/_test_fixtures/table/filters.py +1005 -0
- vgi/_test_fixtures/table/late_materialization.py +249 -0
- vgi/_test_fixtures/table/make_series.py +273 -0
- vgi/_test_fixtures/table/misc.py +499 -0
- vgi/_test_fixtures/table/order_modes.py +164 -0
- vgi/_test_fixtures/table/pairs.py +437 -0
- vgi/_test_fixtures/table/partition_columns.py +472 -0
- vgi/_test_fixtures/table/partition_columns_broken.py +304 -0
- vgi/_test_fixtures/table/profiling_example.py +195 -0
- vgi/_test_fixtures/table/required_filters.py +234 -0
- vgi/_test_fixtures/table/sequence.py +710 -0
- vgi/_test_fixtures/table/settings.py +426 -0
- vgi/_test_fixtures/table/transaction_storage.py +162 -0
- vgi/_test_fixtures/table/tt_pushdown.py +191 -0
- vgi/_test_fixtures/table/versioned.py +230 -0
- vgi/_test_fixtures/table_in_out.py +1392 -0
- vgi/_test_fixtures/versioned.py +155 -0
- vgi/_test_fixtures/versioned_tables.py +595 -0
- vgi/_test_fixtures/worker.py +1631 -0
- vgi/_test_fixtures/writable/__init__.py +8 -0
- vgi/_test_fixtures/writable/generic.py +236 -0
- vgi/_test_fixtures/writable/table.py +149 -0
- vgi/_test_fixtures/writable/worker.py +1148 -0
- vgi/aggregate_function.py +607 -0
- vgi/argument_spec.py +472 -0
- vgi/arguments.py +1747 -0
- vgi/auth.py +55 -0
- vgi/catalog/__init__.py +88 -0
- vgi/catalog/attach_option.py +206 -0
- vgi/catalog/catalog_interface.py +2767 -0
- vgi/catalog/descriptors.py +870 -0
- vgi/catalog/duckdb_statistics.py +377 -0
- vgi/catalog/secret_type.py +96 -0
- vgi/catalog/setting.py +253 -0
- vgi/catalog/storage.py +372 -0
- vgi/client/__init__.py +67 -0
- vgi/client/catalog_mixin.py +1251 -0
- vgi/client/cli.py +582 -0
- vgi/client/cli_catalog.py +182 -0
- vgi/client/cli_schema.py +270 -0
- vgi/client/cli_table.py +907 -0
- vgi/client/cli_transaction.py +97 -0
- vgi/client/cli_utils.py +441 -0
- vgi/client/cli_view.py +303 -0
- vgi/client/client.py +2183 -0
- vgi/exceptions.py +205 -0
- vgi/function.py +245 -0
- vgi/function_storage.py +1636 -0
- vgi/function_storage_azure_sql.py +922 -0
- vgi/function_storage_cf_do.py +740 -0
- vgi/http/__init__.py +25 -0
- vgi/http/demo_storage.py +212 -0
- vgi/http/worker_page.py +1252 -0
- vgi/invocation.py +154 -0
- vgi/logging_config.py +93 -0
- vgi/meta_worker.py +661 -0
- vgi/metadata.py +1403 -0
- vgi/otel.py +406 -0
- vgi/protocol.py +2418 -0
- vgi/protocol_version.txt +1 -0
- vgi/py.typed +0 -0
- vgi/scalar_function.py +1211 -0
- vgi/schema_utils.py +234 -0
- vgi/secret_protocol.py +124 -0
- vgi/secret_service.py +238 -0
- vgi/serve.py +769 -0
- vgi/table_buffering_function.py +443 -0
- vgi/table_filter_pushdown.py +1528 -0
- vgi/table_function.py +1130 -0
- vgi/table_in_out_function.py +383 -0
- vgi/transactor/__init__.py +24 -0
- vgi/transactor/_duckdb_compat.py +27 -0
- vgi/transactor/client.py +137 -0
- vgi/transactor/protocol.py +149 -0
- vgi/transactor/server.py +740 -0
- vgi/worker.py +4761 -0
- vgi_python-0.8.0.dist-info/METADATA +735 -0
- vgi_python-0.8.0.dist-info/RECORD +124 -0
- vgi_python-0.8.0.dist-info/WHEEL +4 -0
- vgi_python-0.8.0.dist-info/entry_points.txt +5 -0
- vgi_python-0.8.0.dist-info/licenses/LICENSE +134 -0
vgi/http/worker_page.py
ADDED
|
@@ -0,0 +1,1252 @@
|
|
|
1
|
+
# Copyright 2025, 2026 Query Farm LLC - https://query.farm
|
|
2
|
+
|
|
3
|
+
"""Worker description page for VGI HTTP servers.
|
|
4
|
+
|
|
5
|
+
Generates a pre-rendered HTML page showing worker metadata:
|
|
6
|
+
- Worker identity and description
|
|
7
|
+
- Functions (scalar, table, table-in-out) with parameters and examples
|
|
8
|
+
- Catalog structure (schemas, tables, views)
|
|
9
|
+
- Settings
|
|
10
|
+
|
|
11
|
+
The page is built once at startup (zero per-request cost) and served
|
|
12
|
+
as a Falcon resource at ``{prefix}/worker``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import html as _html
|
|
18
|
+
import logging
|
|
19
|
+
from collections.abc import Callable, Sequence
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from importlib.metadata import version as _pkg_version
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
23
|
+
|
|
24
|
+
import falcon
|
|
25
|
+
|
|
26
|
+
from vgi.metadata import (
|
|
27
|
+
CatalogFunctionType,
|
|
28
|
+
FunctionStability,
|
|
29
|
+
ResolvedMetadata,
|
|
30
|
+
resolve_metadata,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from vgi.catalog.attach_option import AttachOptionSpec
|
|
35
|
+
from vgi.catalog.catalog_interface import (
|
|
36
|
+
AttachOpaqueData,
|
|
37
|
+
CatalogDataVersionRelease,
|
|
38
|
+
CatalogInterface,
|
|
39
|
+
FunctionInfo,
|
|
40
|
+
SchemaInfo,
|
|
41
|
+
TableInfo,
|
|
42
|
+
ViewInfo,
|
|
43
|
+
)
|
|
44
|
+
from vgi.worker import Worker
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"WorkerPageResource",
|
|
48
|
+
"build_worker_page",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
_logger = logging.getLogger(__name__)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Constants
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
_FONT_IMPORTS = (
|
|
59
|
+
'<link rel="preconnect" href="https://fonts.googleapis.com">'
|
|
60
|
+
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>'
|
|
61
|
+
'<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700'
|
|
62
|
+
'&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">'
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# The three display-level function types we show on the page.
|
|
66
|
+
_TABLE_IN_OUT_CLASS_NAMES = frozenset(
|
|
67
|
+
{
|
|
68
|
+
"TableInOutGenerator",
|
|
69
|
+
"TableInOutFunction",
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _display_function_type(func_cls: type, meta: ResolvedMetadata) -> str:
|
|
75
|
+
"""Return a human-readable function type label.
|
|
76
|
+
|
|
77
|
+
Differentiates table-in-out from plain table functions by walking the MRO.
|
|
78
|
+
"""
|
|
79
|
+
if meta.function_type == CatalogFunctionType.SCALAR:
|
|
80
|
+
return "scalar"
|
|
81
|
+
if meta.function_type == CatalogFunctionType.AGGREGATE:
|
|
82
|
+
return "aggregate"
|
|
83
|
+
# TABLE — check if it's actually a table-in-out
|
|
84
|
+
for klass in func_cls.__mro__:
|
|
85
|
+
if klass.__name__ in _TABLE_IN_OUT_CLASS_NAMES:
|
|
86
|
+
return "table-in-out"
|
|
87
|
+
return "table"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
_BADGE_CSS_CLASS = {
|
|
91
|
+
"scalar": "badge-scalar",
|
|
92
|
+
"table": "badge-table",
|
|
93
|
+
"table-in-out": "badge-table-in-out",
|
|
94
|
+
"aggregate": "badge-aggregate",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# HTML helpers
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _esc(value: object) -> str:
|
|
104
|
+
return _html.escape(str(value))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _build_param_row(p) -> str: # type: ignore[no-untyped-def] # ParameterInfo
|
|
108
|
+
"""Build a table row for a single function parameter."""
|
|
109
|
+
badges = ""
|
|
110
|
+
if p.is_const:
|
|
111
|
+
badges += ' <span class="mini-badge mini-const">const</span>'
|
|
112
|
+
if p.is_varargs:
|
|
113
|
+
badges += ' <span class="mini-badge mini-varargs">varargs</span>'
|
|
114
|
+
if p.is_table_input:
|
|
115
|
+
badges += ' <span class="mini-badge mini-table-input">table input</span>'
|
|
116
|
+
|
|
117
|
+
type_str = _esc(p.type_name) if p.type_name else "—"
|
|
118
|
+
default_str = _esc(repr(p.default)) if p.default is not None else "—"
|
|
119
|
+
desc_str = _esc(p.description) if p.description else "—"
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
f"<tr>"
|
|
123
|
+
f"<td><code>{_esc(p.name)}</code>{badges}</td>"
|
|
124
|
+
f"<td><code>{type_str}</code></td>"
|
|
125
|
+
f"<td>{default_str}</td>"
|
|
126
|
+
f"<td>{desc_str}</td>"
|
|
127
|
+
f"</tr>"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _build_function_card(func_cls: type, meta: ResolvedMetadata) -> str:
|
|
132
|
+
"""Build the HTML card for a single function."""
|
|
133
|
+
display_type = _display_function_type(func_cls, meta)
|
|
134
|
+
badge_cls = _BADGE_CSS_CLASS.get(display_type, "badge-table")
|
|
135
|
+
|
|
136
|
+
parts: list[str] = [
|
|
137
|
+
'<div class="card">',
|
|
138
|
+
'<div class="card-header">',
|
|
139
|
+
f'<span class="method-name">{_esc(meta.name)}</span>',
|
|
140
|
+
f'<span class="badge {badge_cls}">{_esc(display_type)}</span>',
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
# Stability badge (only if not the default CONSISTENT)
|
|
144
|
+
if meta.stability != FunctionStability.CONSISTENT:
|
|
145
|
+
parts.append(f'<span class="badge badge-stability">{_esc(meta.stability.name.lower())}</span>')
|
|
146
|
+
|
|
147
|
+
parts.append("</div>") # close card-header
|
|
148
|
+
|
|
149
|
+
# Description
|
|
150
|
+
if meta.description:
|
|
151
|
+
parts.append(f'<p class="docstring">{_esc(meta.description)}</p>')
|
|
152
|
+
|
|
153
|
+
# Parameters table (skip table_input params for cleaner display)
|
|
154
|
+
visible_params = [p for p in meta.parameters if not p.is_table_input]
|
|
155
|
+
if visible_params:
|
|
156
|
+
parts.append('<div class="section-label">Parameters</div>')
|
|
157
|
+
parts.append("<table><tr><th>Name</th><th>Type</th><th>Default</th><th>Description</th></tr>")
|
|
158
|
+
for p in visible_params:
|
|
159
|
+
parts.append(_build_param_row(p))
|
|
160
|
+
parts.append("</table>")
|
|
161
|
+
else:
|
|
162
|
+
parts.append('<p class="no-params">No parameters</p>')
|
|
163
|
+
|
|
164
|
+
# Examples
|
|
165
|
+
if meta.examples:
|
|
166
|
+
parts.append('<div class="section-label">Examples</div>')
|
|
167
|
+
for ex in meta.examples:
|
|
168
|
+
if ex.description:
|
|
169
|
+
parts.append(f'<p class="example-desc">{_esc(ex.description)}</p>')
|
|
170
|
+
parts.append(f"<pre><code>{_esc(ex.sql)}</code></pre>")
|
|
171
|
+
|
|
172
|
+
# Capabilities (for table functions)
|
|
173
|
+
caps: list[str] = []
|
|
174
|
+
if meta.filter_pushdown:
|
|
175
|
+
caps.append("filter pushdown")
|
|
176
|
+
if meta.projection_pushdown:
|
|
177
|
+
caps.append("projection pushdown")
|
|
178
|
+
if meta.max_workers is not None:
|
|
179
|
+
caps.append(f"max_workers={meta.max_workers}")
|
|
180
|
+
if caps:
|
|
181
|
+
parts.append('<div class="section-label">Capabilities</div>')
|
|
182
|
+
parts.append(
|
|
183
|
+
'<div class="caps">' + " ".join(f'<span class="cap-tag">{_esc(c)}</span>' for c in caps) + "</div>"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
parts.append("</div>") # close card
|
|
187
|
+
return "\n".join(parts)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _build_table_card(table) -> str: # type: ignore[no-untyped-def] # catalog Table
|
|
191
|
+
"""Build an HTML card for a catalog table."""
|
|
192
|
+
parts: list[str] = [
|
|
193
|
+
'<div class="card">',
|
|
194
|
+
'<div class="card-header">',
|
|
195
|
+
f'<span class="method-name">{_esc(table.name)}</span>',
|
|
196
|
+
'<span class="badge badge-table-obj">table</span>',
|
|
197
|
+
]
|
|
198
|
+
if table.function is not None:
|
|
199
|
+
parts.append('<span class="mini-badge mini-func-backed">function-backed</span>')
|
|
200
|
+
parts.append("</div>")
|
|
201
|
+
|
|
202
|
+
if table.comment:
|
|
203
|
+
parts.append(f'<p class="docstring">{_esc(table.comment)}</p>')
|
|
204
|
+
|
|
205
|
+
# Columns
|
|
206
|
+
try:
|
|
207
|
+
cols = table.resolved_columns
|
|
208
|
+
except Exception:
|
|
209
|
+
cols = None
|
|
210
|
+
|
|
211
|
+
if cols is not None and len(cols) > 0:
|
|
212
|
+
parts.append('<div class="section-label">Columns</div>')
|
|
213
|
+
parts.append("<table><tr><th>Name</th><th>Type</th></tr>")
|
|
214
|
+
for field in cols:
|
|
215
|
+
parts.append(
|
|
216
|
+
f"<tr><td><code>{_esc(field.name)}</code></td><td><code>{_esc(str(field.type))}</code></td></tr>"
|
|
217
|
+
)
|
|
218
|
+
parts.append("</table>")
|
|
219
|
+
|
|
220
|
+
parts.append("</div>")
|
|
221
|
+
return "\n".join(parts)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _build_view_card(view) -> str: # type: ignore[no-untyped-def] # catalog View
|
|
225
|
+
"""Build an HTML card for a catalog view."""
|
|
226
|
+
parts: list[str] = [
|
|
227
|
+
'<div class="card">',
|
|
228
|
+
'<div class="card-header">',
|
|
229
|
+
f'<span class="method-name">{_esc(view.name)}</span>',
|
|
230
|
+
'<span class="badge badge-view">view</span>',
|
|
231
|
+
"</div>",
|
|
232
|
+
]
|
|
233
|
+
if view.comment:
|
|
234
|
+
parts.append(f'<p class="docstring">{_esc(view.comment)}</p>')
|
|
235
|
+
parts.append('<div class="section-label">Definition</div>')
|
|
236
|
+
parts.append(f"<pre><code>{_esc(view.definition)}</code></pre>")
|
|
237
|
+
parts.append("</div>")
|
|
238
|
+
return "\n".join(parts)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass(frozen=True)
|
|
242
|
+
class _CatalogPanel:
|
|
243
|
+
"""Per-catalog descriptor used to render the connect section."""
|
|
244
|
+
|
|
245
|
+
name: str
|
|
246
|
+
implementation_version: str | None = None
|
|
247
|
+
data_version_spec: str | None = None
|
|
248
|
+
attach_option_specs: tuple[AttachOptionSpec, ...] = ()
|
|
249
|
+
comment: str | None = None
|
|
250
|
+
# Published data-version releases, newest-first. Duplicates by
|
|
251
|
+
# ``version`` are dropped at deserialization (uniqueness contract).
|
|
252
|
+
releases: tuple[CatalogDataVersionRelease, ...] = ()
|
|
253
|
+
# Optional link to where the worker's code lives.
|
|
254
|
+
source_url: str | None = None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _collect_catalog_panels(worker_cls: type[Worker]) -> list[_CatalogPanel]:
|
|
258
|
+
"""Enumerate the catalogs this worker exposes, with their attach options.
|
|
259
|
+
|
|
260
|
+
Tries to instantiate the worker's catalog interface and call ``catalogs()``,
|
|
261
|
+
so workers that override discovery to advertise multiple catalogs surface
|
|
262
|
+
correctly. Falls back to the static ``cls.catalog`` descriptor (or the
|
|
263
|
+
legacy ``catalog_name``) when instantiation isn't viable at page-build time.
|
|
264
|
+
"""
|
|
265
|
+
from vgi_rpc.utils import deserialize_record_batch
|
|
266
|
+
|
|
267
|
+
from vgi.catalog.attach_option import AttachOptionSpec
|
|
268
|
+
|
|
269
|
+
static_catalog = getattr(worker_cls, "catalog", None)
|
|
270
|
+
static_name = static_catalog.name if static_catalog is not None else None
|
|
271
|
+
static_comment = static_catalog.comment if static_catalog is not None else None
|
|
272
|
+
|
|
273
|
+
iface_cls = None
|
|
274
|
+
try:
|
|
275
|
+
iface_cls = worker_cls._get_catalog_interface()
|
|
276
|
+
except Exception: # noqa: BLE001 — best-effort discovery
|
|
277
|
+
_logger.debug("could not resolve catalog interface for %s", worker_cls.__name__, exc_info=True)
|
|
278
|
+
|
|
279
|
+
if iface_cls is not None:
|
|
280
|
+
try:
|
|
281
|
+
iface = iface_cls()
|
|
282
|
+
infos = list(iface.catalogs())
|
|
283
|
+
except Exception: # noqa: BLE001 — fall back to static descriptor
|
|
284
|
+
_logger.debug("catalog_interface.catalogs() failed for %s", worker_cls.__name__, exc_info=True)
|
|
285
|
+
infos = []
|
|
286
|
+
|
|
287
|
+
panels: list[_CatalogPanel] = []
|
|
288
|
+
for info in infos:
|
|
289
|
+
specs: list[AttachOptionSpec] = []
|
|
290
|
+
for raw in info.attach_option_specs or ():
|
|
291
|
+
try:
|
|
292
|
+
batch, _ = deserialize_record_batch(bytes(raw))
|
|
293
|
+
specs.append(AttachOptionSpec.deserialize(batch))
|
|
294
|
+
except Exception: # noqa: BLE001 — drop unparseable spec, keep the page
|
|
295
|
+
_logger.debug("failed to deserialize attach option spec", exc_info=True)
|
|
296
|
+
|
|
297
|
+
# Defend against duplicate ``version`` entries — the contract is
|
|
298
|
+
# one entry per version, but Arrow can't enforce that.
|
|
299
|
+
seen_versions: set[str] = set()
|
|
300
|
+
releases: list[CatalogDataVersionRelease] = []
|
|
301
|
+
for release in info.releases or ():
|
|
302
|
+
if release.version in seen_versions:
|
|
303
|
+
_logger.warning(
|
|
304
|
+
"duplicate version %r in releases for catalog %r; dropping later entry",
|
|
305
|
+
release.version,
|
|
306
|
+
info.name,
|
|
307
|
+
)
|
|
308
|
+
continue
|
|
309
|
+
seen_versions.add(release.version)
|
|
310
|
+
releases.append(release)
|
|
311
|
+
|
|
312
|
+
panels.append(
|
|
313
|
+
_CatalogPanel(
|
|
314
|
+
name=info.name,
|
|
315
|
+
implementation_version=info.implementation_version,
|
|
316
|
+
data_version_spec=info.data_version_spec,
|
|
317
|
+
attach_option_specs=tuple(specs),
|
|
318
|
+
comment=static_comment if info.name == static_name else None,
|
|
319
|
+
releases=tuple(releases),
|
|
320
|
+
source_url=info.source_url,
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
if panels:
|
|
324
|
+
return panels
|
|
325
|
+
|
|
326
|
+
# Fallback: single catalog from the static descriptor or legacy attribute.
|
|
327
|
+
fallback_name = static_name or getattr(worker_cls, "catalog_name", None) or worker_cls.__name__.lower()
|
|
328
|
+
static_specs = tuple(getattr(worker_cls, "_attach_option_specs", ()) or ())
|
|
329
|
+
return [
|
|
330
|
+
_CatalogPanel(
|
|
331
|
+
name=fallback_name,
|
|
332
|
+
attach_option_specs=static_specs,
|
|
333
|
+
comment=static_comment,
|
|
334
|
+
)
|
|
335
|
+
]
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _attach_for_describe(
|
|
339
|
+
worker_cls: type[Worker],
|
|
340
|
+
catalog_name: str,
|
|
341
|
+
requested_data_version: str | None,
|
|
342
|
+
) -> tuple[CatalogInterface | None, AttachOpaqueData | None, str | None]:
|
|
343
|
+
"""Pre-attach to validate the requested version and capture an attach_opaque_data.
|
|
344
|
+
|
|
345
|
+
Returns ``(iface, attach_opaque_data, error)``:
|
|
346
|
+
|
|
347
|
+
* ``iface`` is the instantiated catalog interface (or ``None`` if the
|
|
348
|
+
worker has none).
|
|
349
|
+
* ``attach_opaque_data`` is non-``None`` when the attach succeeded; the page uses
|
|
350
|
+
it to dynamically enumerate schemas/tables/views/functions for the
|
|
351
|
+
resolved data version.
|
|
352
|
+
* ``error`` is non-``None`` when the worker rejected the requested
|
|
353
|
+
version (e.g. ``"Unsupported data_version_spec '1.1.1'; this worker
|
|
354
|
+
serves one of ['1.0.0', '1.1.0', '1.2.0']"``). The describe page
|
|
355
|
+
surfaces it as a red banner; the Apply form stays interactive so the
|
|
356
|
+
user can fix the value and resubmit.
|
|
357
|
+
"""
|
|
358
|
+
iface_cls = None
|
|
359
|
+
try:
|
|
360
|
+
iface_cls = worker_cls._get_catalog_interface()
|
|
361
|
+
except Exception: # noqa: BLE001 — best-effort; absence of interface is fine
|
|
362
|
+
_logger.debug("could not resolve catalog interface for %s", worker_cls.__name__, exc_info=True)
|
|
363
|
+
if iface_cls is None:
|
|
364
|
+
return None, None, None
|
|
365
|
+
try:
|
|
366
|
+
iface = iface_cls()
|
|
367
|
+
except Exception: # noqa: BLE001 — instantiation failure shouldn't kill the page
|
|
368
|
+
_logger.debug("catalog interface instantiation failed for %s", worker_cls.__name__, exc_info=True)
|
|
369
|
+
return None, None, None
|
|
370
|
+
try:
|
|
371
|
+
result = iface.catalog_attach(
|
|
372
|
+
name=catalog_name,
|
|
373
|
+
options={},
|
|
374
|
+
data_version_spec=requested_data_version,
|
|
375
|
+
implementation_version=None,
|
|
376
|
+
)
|
|
377
|
+
except Exception as exc: # noqa: BLE001 — surface whatever the worker raises
|
|
378
|
+
return iface, None, str(exc) or exc.__class__.__name__
|
|
379
|
+
return iface, result.attach_opaque_data, None
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _build_dynamic_table_card(t: TableInfo) -> str:
|
|
383
|
+
"""Render a TableInfo (from iface.schema_contents) as a card."""
|
|
384
|
+
import pyarrow as pa
|
|
385
|
+
|
|
386
|
+
parts: list[str] = [
|
|
387
|
+
'<div class="card">',
|
|
388
|
+
'<div class="card-header">',
|
|
389
|
+
f'<span class="method-name">{_esc(t.name)}</span>',
|
|
390
|
+
'<span class="badge badge-table-obj">table</span>',
|
|
391
|
+
"</div>",
|
|
392
|
+
]
|
|
393
|
+
if t.comment:
|
|
394
|
+
parts.append(f'<p class="docstring">{_esc(t.comment)}</p>')
|
|
395
|
+
schema = None
|
|
396
|
+
try:
|
|
397
|
+
schema = pa.ipc.read_schema(pa.py_buffer(bytes(t.columns)))
|
|
398
|
+
except Exception: # noqa: BLE001
|
|
399
|
+
_logger.debug("failed to deserialize table columns for %s", t.name, exc_info=True)
|
|
400
|
+
if schema is not None and len(schema) > 0:
|
|
401
|
+
parts.append('<div class="section-label">Columns</div>')
|
|
402
|
+
parts.append("<table><tr><th>Name</th><th>Type</th></tr>")
|
|
403
|
+
for fld in schema:
|
|
404
|
+
parts.append(f"<tr><td><code>{_esc(fld.name)}</code></td><td><code>{_esc(str(fld.type))}</code></td></tr>")
|
|
405
|
+
parts.append("</table>")
|
|
406
|
+
parts.append("</div>")
|
|
407
|
+
return "\n".join(parts)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _build_dynamic_view_card(v: ViewInfo) -> str:
|
|
411
|
+
"""Render a ViewInfo as a card."""
|
|
412
|
+
parts: list[str] = [
|
|
413
|
+
'<div class="card">',
|
|
414
|
+
'<div class="card-header">',
|
|
415
|
+
f'<span class="method-name">{_esc(v.name)}</span>',
|
|
416
|
+
'<span class="badge badge-view">view</span>',
|
|
417
|
+
"</div>",
|
|
418
|
+
]
|
|
419
|
+
if v.comment:
|
|
420
|
+
parts.append(f'<p class="docstring">{_esc(v.comment)}</p>')
|
|
421
|
+
parts.append('<div class="section-label">Definition</div>')
|
|
422
|
+
parts.append(f"<pre><code>{_esc(v.definition)}</code></pre>")
|
|
423
|
+
parts.append("</div>")
|
|
424
|
+
return "\n".join(parts)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _build_dynamic_function_card(fn: FunctionInfo) -> str:
|
|
428
|
+
"""Render a FunctionInfo as a card.
|
|
429
|
+
|
|
430
|
+
FunctionInfo doesn't preserve the table-vs-table-in-out distinction —
|
|
431
|
+
the protocol-level ``function_type`` lumps them together. Use
|
|
432
|
+
``has_finalize`` as a heuristic for table-in-out so the badge stays
|
|
433
|
+
accurate where possible.
|
|
434
|
+
"""
|
|
435
|
+
import pyarrow as pa
|
|
436
|
+
|
|
437
|
+
from vgi.catalog.catalog_interface import FunctionType
|
|
438
|
+
|
|
439
|
+
if fn.function_type == FunctionType.SCALAR:
|
|
440
|
+
display = "scalar"
|
|
441
|
+
elif fn.function_type == FunctionType.AGGREGATE:
|
|
442
|
+
display = "aggregate"
|
|
443
|
+
elif fn.has_finalize:
|
|
444
|
+
display = "table-in-out"
|
|
445
|
+
else:
|
|
446
|
+
display = "table"
|
|
447
|
+
badge_cls = _BADGE_CSS_CLASS.get(display, "badge-table")
|
|
448
|
+
|
|
449
|
+
parts: list[str] = [
|
|
450
|
+
'<div class="card">',
|
|
451
|
+
'<div class="card-header">',
|
|
452
|
+
f'<span class="method-name">{_esc(fn.name)}</span>',
|
|
453
|
+
f'<span class="badge {badge_cls}">{_esc(display)}</span>',
|
|
454
|
+
]
|
|
455
|
+
if fn.stability is not None and fn.stability != FunctionStability.CONSISTENT:
|
|
456
|
+
parts.append(f'<span class="badge badge-stability">{_esc(fn.stability.name.lower())}</span>')
|
|
457
|
+
parts.append("</div>")
|
|
458
|
+
|
|
459
|
+
if fn.description:
|
|
460
|
+
parts.append(f'<p class="docstring">{_esc(fn.description)}</p>')
|
|
461
|
+
|
|
462
|
+
args_schema = None
|
|
463
|
+
try:
|
|
464
|
+
args_schema = pa.ipc.read_schema(pa.py_buffer(bytes(fn.arguments)))
|
|
465
|
+
except Exception: # noqa: BLE001
|
|
466
|
+
_logger.debug("failed to deserialize function arguments for %s", fn.name, exc_info=True)
|
|
467
|
+
if args_schema is not None and len(args_schema) > 0:
|
|
468
|
+
parts.append('<div class="section-label">Parameters</div>')
|
|
469
|
+
parts.append("<table><tr><th>Name</th><th>Type</th></tr>")
|
|
470
|
+
for fld in args_schema:
|
|
471
|
+
parts.append(f"<tr><td><code>{_esc(fld.name)}</code></td><td><code>{_esc(str(fld.type))}</code></td></tr>")
|
|
472
|
+
parts.append("</table>")
|
|
473
|
+
else:
|
|
474
|
+
parts.append('<p class="no-params">No parameters</p>')
|
|
475
|
+
|
|
476
|
+
if fn.examples:
|
|
477
|
+
parts.append('<div class="section-label">Examples</div>')
|
|
478
|
+
for ex in fn.examples:
|
|
479
|
+
if ex.description:
|
|
480
|
+
parts.append(f'<p class="example-desc">{_esc(ex.description)}</p>')
|
|
481
|
+
parts.append(f"<pre><code>{_esc(ex.sql)}</code></pre>")
|
|
482
|
+
|
|
483
|
+
caps: list[str] = []
|
|
484
|
+
if fn.filter_pushdown:
|
|
485
|
+
caps.append("filter pushdown")
|
|
486
|
+
if fn.projection_pushdown:
|
|
487
|
+
caps.append("projection pushdown")
|
|
488
|
+
if fn.max_workers is not None:
|
|
489
|
+
caps.append(f"max_workers={fn.max_workers}")
|
|
490
|
+
if caps:
|
|
491
|
+
parts.append('<div class="section-label">Capabilities</div>')
|
|
492
|
+
parts.append(
|
|
493
|
+
'<div class="caps">' + " ".join(f'<span class="cap-tag">{_esc(c)}</span>' for c in caps) + "</div>"
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
parts.append("</div>")
|
|
497
|
+
return "\n".join(parts)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _render_dynamic_schemas(iface: CatalogInterface, attach_opaque_data: AttachOpaqueData) -> list[str]:
|
|
501
|
+
"""Enumerate schemas/tables/views/functions from the attached catalog.
|
|
502
|
+
|
|
503
|
+
Returns a flat list of HTML fragments (matching the static path's
|
|
504
|
+
``body_parts`` shape). Best-effort: any exception from the interface
|
|
505
|
+
falls through to an empty list so the page still renders.
|
|
506
|
+
"""
|
|
507
|
+
from vgi.catalog.catalog_interface import SchemaObjectType
|
|
508
|
+
|
|
509
|
+
try:
|
|
510
|
+
schemas: Sequence[SchemaInfo] = iface.schemas(
|
|
511
|
+
attach_opaque_data=attach_opaque_data, transaction_opaque_data=None
|
|
512
|
+
)
|
|
513
|
+
except Exception: # noqa: BLE001
|
|
514
|
+
_logger.debug("iface.schemas() failed", exc_info=True)
|
|
515
|
+
return []
|
|
516
|
+
|
|
517
|
+
def _safe_contents(name: str, kind: SchemaObjectType) -> list[Any]:
|
|
518
|
+
# The overloads on schema_contents key off Literal[...] values;
|
|
519
|
+
# passing a runtime variable defeats the dispatch, so we cast to
|
|
520
|
+
# Any to fall through to the implementation method.
|
|
521
|
+
contents: Any = iface.schema_contents
|
|
522
|
+
try:
|
|
523
|
+
result = contents(attach_opaque_data=attach_opaque_data, transaction_opaque_data=None, name=name, type=kind)
|
|
524
|
+
except Exception: # noqa: BLE001
|
|
525
|
+
_logger.debug("schema_contents(%s) failed for %s", kind, name, exc_info=True)
|
|
526
|
+
return []
|
|
527
|
+
return list(result)
|
|
528
|
+
|
|
529
|
+
out: list[str] = []
|
|
530
|
+
for schema_info in schemas:
|
|
531
|
+
out.append(f'<h2 class="schema-heading">{_esc(schema_info.name)}</h2>')
|
|
532
|
+
if schema_info.comment:
|
|
533
|
+
out.append(f'<p class="schema-comment">{_esc(schema_info.comment)}</p>')
|
|
534
|
+
|
|
535
|
+
# Functions (scalar / table / aggregate, sorted by type then name).
|
|
536
|
+
funcs: list[FunctionInfo] = []
|
|
537
|
+
funcs.extend(_safe_contents(schema_info.name, SchemaObjectType.SCALAR_FUNCTION))
|
|
538
|
+
funcs.extend(_safe_contents(schema_info.name, SchemaObjectType.TABLE_FUNCTION))
|
|
539
|
+
funcs.extend(_safe_contents(schema_info.name, SchemaObjectType.AGGREGATE_FUNCTION))
|
|
540
|
+
if funcs:
|
|
541
|
+
out.append('<div class="section-label">Functions</div>')
|
|
542
|
+
for fn in sorted(funcs, key=lambda f: (f.function_type.value, f.name)):
|
|
543
|
+
out.append(_build_dynamic_function_card(fn))
|
|
544
|
+
|
|
545
|
+
# Tables.
|
|
546
|
+
tables = list(_safe_contents(schema_info.name, SchemaObjectType.TABLE))
|
|
547
|
+
if tables:
|
|
548
|
+
out.append('<div class="section-label">Tables</div>')
|
|
549
|
+
for t in tables:
|
|
550
|
+
out.append(_build_dynamic_table_card(t))
|
|
551
|
+
|
|
552
|
+
# Views.
|
|
553
|
+
views = list(_safe_contents(schema_info.name, SchemaObjectType.VIEW))
|
|
554
|
+
if views:
|
|
555
|
+
out.append('<div class="section-label">Views</div>')
|
|
556
|
+
for v in views:
|
|
557
|
+
out.append(_build_dynamic_view_card(v))
|
|
558
|
+
|
|
559
|
+
return out
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _build_attach_options_table(specs: tuple[AttachOptionSpec, ...]) -> str:
|
|
563
|
+
"""Render the attach-options table for a single catalog."""
|
|
564
|
+
if not specs:
|
|
565
|
+
return ""
|
|
566
|
+
parts: list[str] = [
|
|
567
|
+
'<div class="section-label">Attach options</div>',
|
|
568
|
+
"<table><tr><th>Name</th><th>Type</th><th>Default</th><th>Description</th></tr>",
|
|
569
|
+
]
|
|
570
|
+
for spec in specs:
|
|
571
|
+
default_str = _esc(repr(spec.default)) if spec.default is not None else "—"
|
|
572
|
+
desc_str = _esc(spec.desc) if spec.desc else "—"
|
|
573
|
+
parts.append(
|
|
574
|
+
f"<tr><td><code>{_esc(spec.name)}</code></td>"
|
|
575
|
+
f"<td><code>{_esc(str(spec.type))}</code></td>"
|
|
576
|
+
f"<td>{default_str}</td>"
|
|
577
|
+
f"<td>{desc_str}</td></tr>"
|
|
578
|
+
)
|
|
579
|
+
parts.append("</table>")
|
|
580
|
+
return "\n".join(parts)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _build_attach_sql(catalog_name: str, panel_id: str, requested_version: str | None) -> str:
|
|
584
|
+
"""Render the ATTACH SQL block (with copy button) for one catalog.
|
|
585
|
+
|
|
586
|
+
When ``requested_version`` is provided (the user clicked Apply), the
|
|
587
|
+
``data_version_spec`` clause renders inline so the SQL the user copies
|
|
588
|
+
matches the version they submitted. Otherwise the clause is hidden and
|
|
589
|
+
JS toggles it as the user edits the input.
|
|
590
|
+
"""
|
|
591
|
+
if requested_version:
|
|
592
|
+
clause = (
|
|
593
|
+
'<span class="dv-clause">, data_version_spec \''
|
|
594
|
+
f'<span class="dv-value">{_esc(requested_version)}</span>\'</span>'
|
|
595
|
+
)
|
|
596
|
+
else:
|
|
597
|
+
clause = '<span class="dv-clause" hidden>, data_version_spec \'<span class="dv-value"></span>\'</span>'
|
|
598
|
+
return (
|
|
599
|
+
'<div class="connect-label">Connect with DuckDB'
|
|
600
|
+
f'<button class="copy-btn" data-copy-target="sql-{panel_id}" title="Copy to clipboard">'
|
|
601
|
+
'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">'
|
|
602
|
+
'<rect x="5.5" y="5.5" width="9" height="9" rx="1.5"/>'
|
|
603
|
+
'<path d="M10.5 5.5V2.5a1 1 0 00-1-1h-7a1 1 0 00-1 1v7a1 1 0 001 1h3"/>'
|
|
604
|
+
"</svg></button></div>"
|
|
605
|
+
f'<pre><code id="sql-{panel_id}" class="connect-sql" data-catalog="{_esc(catalog_name)}">'
|
|
606
|
+
f"ATTACH '{_esc(catalog_name)}' AS {_esc(catalog_name)}"
|
|
607
|
+
" (TYPE vgi, LOCATION '<span class=\"connect-url\">{location}</span>'"
|
|
608
|
+
f"{clause}"
|
|
609
|
+
");</code></pre>"
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _build_release_timeline(panel: _CatalogPanel) -> str:
|
|
614
|
+
"""Render the data-version release timeline for one catalog.
|
|
615
|
+
|
|
616
|
+
Each row carries the version (clickable to fill the catalog's
|
|
617
|
+
``.dv-input``), the release date, the one-line summary, and an
|
|
618
|
+
optional ``details →`` link when ``notes_url`` is set. The catalog
|
|
619
|
+
enforces newest-first ordering on the wire; we render in the order
|
|
620
|
+
received.
|
|
621
|
+
"""
|
|
622
|
+
parts: list[str] = [
|
|
623
|
+
'<div class="release-timeline" '
|
|
624
|
+
f'data-catalog="{_esc(panel.name)}">'
|
|
625
|
+
'<div class="section-label">Releases</div>'
|
|
626
|
+
'<ul class="release-list">'
|
|
627
|
+
]
|
|
628
|
+
for release in panel.releases:
|
|
629
|
+
date_str = release.released_at.strftime("%Y-%m-%d") if release.released_at is not None else ""
|
|
630
|
+
notes = (
|
|
631
|
+
f' <a class="release-details" href="{_esc(release.notes_url)}" '
|
|
632
|
+
'target="_blank" rel="noopener">details →</a>'
|
|
633
|
+
if release.notes_url
|
|
634
|
+
else ""
|
|
635
|
+
)
|
|
636
|
+
summary = f'<span class="release-summary">{_esc(release.summary)}</span>' if release.summary else ""
|
|
637
|
+
parts.append(
|
|
638
|
+
"<li>"
|
|
639
|
+
f'<button type="button" class="release-version" '
|
|
640
|
+
f'data-catalog="{_esc(panel.name)}" data-version="{_esc(release.version)}">'
|
|
641
|
+
f"{_esc(release.version)}</button>"
|
|
642
|
+
f'<span class="release-date">{_esc(date_str)}</span>'
|
|
643
|
+
f"{summary}{notes}"
|
|
644
|
+
"</li>"
|
|
645
|
+
)
|
|
646
|
+
parts.append("</ul></div>")
|
|
647
|
+
return "\n".join(parts)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _build_connect_section(
|
|
651
|
+
panels: list[_CatalogPanel],
|
|
652
|
+
prefix: str,
|
|
653
|
+
*,
|
|
654
|
+
active_catalog: str | None = None,
|
|
655
|
+
requested_data_version: str | None = None,
|
|
656
|
+
) -> str:
|
|
657
|
+
"""Build the Connect section: optional catalog selector + per-catalog panel.
|
|
658
|
+
|
|
659
|
+
``active_catalog`` and ``requested_data_version`` come from the request
|
|
660
|
+
query string. When ``active_catalog`` matches a panel, that panel is the
|
|
661
|
+
one rendered without ``hidden``; the data version input on that panel is
|
|
662
|
+
pre-filled with ``requested_data_version`` and the ATTACH SQL bakes the
|
|
663
|
+
clause inline.
|
|
664
|
+
"""
|
|
665
|
+
active_index = 0
|
|
666
|
+
if active_catalog is not None:
|
|
667
|
+
for i, panel in enumerate(panels):
|
|
668
|
+
if panel.name == active_catalog:
|
|
669
|
+
active_index = i
|
|
670
|
+
break
|
|
671
|
+
|
|
672
|
+
parts: list[str] = ['<div class="connect-box">']
|
|
673
|
+
|
|
674
|
+
multi = len(panels) > 1
|
|
675
|
+
if multi:
|
|
676
|
+
parts.append('<div class="catalog-tabs" role="tablist">')
|
|
677
|
+
for i, panel in enumerate(panels):
|
|
678
|
+
active = " active" if i == active_index else ""
|
|
679
|
+
parts.append(
|
|
680
|
+
f'<button class="catalog-tab{active}" role="tab" data-panel="catpanel-{i}">{_esc(panel.name)}</button>'
|
|
681
|
+
)
|
|
682
|
+
parts.append("</div>")
|
|
683
|
+
|
|
684
|
+
for i, panel in enumerate(panels):
|
|
685
|
+
hidden_attr = "" if i == active_index else " hidden"
|
|
686
|
+
parts.append(
|
|
687
|
+
f'<div class="catalog-panel" id="catpanel-{i}" role="tabpanel" '
|
|
688
|
+
f'data-catalog="{_esc(panel.name)}"{hidden_attr}>'
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
if panel.comment:
|
|
692
|
+
parts.append(f'<p class="catalog-comment">{_esc(panel.comment)}</p>')
|
|
693
|
+
|
|
694
|
+
# Implementation chip + optional source link. We render both inline
|
|
695
|
+
# in one row so they line up visually; the source link can appear on
|
|
696
|
+
# its own when there's no implementation version.
|
|
697
|
+
impl_bits: list[str] = []
|
|
698
|
+
if panel.implementation_version:
|
|
699
|
+
impl_bits.append(
|
|
700
|
+
f'<span class="impl-chip">Implementation <code>{_esc(panel.implementation_version)}</code></span>'
|
|
701
|
+
)
|
|
702
|
+
if panel.source_url:
|
|
703
|
+
impl_bits.append(
|
|
704
|
+
f'<a class="source-link" href="{_esc(panel.source_url)}" target="_blank" rel="noopener">'
|
|
705
|
+
"View source →</a>"
|
|
706
|
+
)
|
|
707
|
+
if impl_bits:
|
|
708
|
+
parts.append('<div class="impl-row">' + " ".join(impl_bits) + "</div>")
|
|
709
|
+
|
|
710
|
+
# Apply form: GET reload with ?catalog=&data_version_spec= so the URL
|
|
711
|
+
# is the source of truth for the user's chosen version.
|
|
712
|
+
is_active_panel = i == active_index
|
|
713
|
+
prefilled = requested_data_version if is_active_panel else None
|
|
714
|
+
sql_version = prefilled if prefilled else None
|
|
715
|
+
value_attr = f' value="{_esc(prefilled)}"' if prefilled else ""
|
|
716
|
+
|
|
717
|
+
if panel.data_version_spec:
|
|
718
|
+
parts.append(
|
|
719
|
+
f'<form class="dv-form" method="get" data-catalog="{_esc(panel.name)}">'
|
|
720
|
+
f'<input type="hidden" name="catalog" value="{_esc(panel.name)}">'
|
|
721
|
+
'<label class="dv-row">'
|
|
722
|
+
'<span class="dv-label">Data version</span>'
|
|
723
|
+
f'<input type="text" class="dv-input" name="data_version_spec" placeholder="latest"'
|
|
724
|
+
f' data-catalog="{_esc(panel.name)}"'
|
|
725
|
+
f' aria-label="Data version for {_esc(panel.name)}"{value_attr}>'
|
|
726
|
+
'<button type="submit" class="dv-apply">Apply</button>'
|
|
727
|
+
f'<span class="dv-hint">supported: <code>{_esc(panel.data_version_spec)}</code></span>'
|
|
728
|
+
"</label>"
|
|
729
|
+
"</form>"
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
if panel.releases:
|
|
733
|
+
parts.append(_build_release_timeline(panel))
|
|
734
|
+
|
|
735
|
+
parts.append(_build_attach_sql(panel.name, str(i), sql_version))
|
|
736
|
+
parts.append(_build_attach_options_table(panel.attach_option_specs))
|
|
737
|
+
|
|
738
|
+
parts.append("</div>") # /catalog-panel
|
|
739
|
+
|
|
740
|
+
# JS: substitute {location}, wire copy buttons + tab switching + data
|
|
741
|
+
# version input, and keep the Cupola button href in sync with the active
|
|
742
|
+
# catalog and (when set) data_version_spec. Wrapped in DOMContentLoaded
|
|
743
|
+
# because the Cupola button element lives outside this connect-section
|
|
744
|
+
# (rendered later by build_worker_page).
|
|
745
|
+
parts.append(
|
|
746
|
+
'<script>document.addEventListener("DOMContentLoaded",function(){'
|
|
747
|
+
f'var u=location.origin+"{_esc(prefix)}";'
|
|
748
|
+
'document.querySelectorAll(".connect-sql").forEach(function(el){'
|
|
749
|
+
'el.innerHTML=el.innerHTML.replace("{location}",u);});'
|
|
750
|
+
# Copy buttons
|
|
751
|
+
'document.querySelectorAll(".copy-btn").forEach(function(btn){'
|
|
752
|
+
'btn.addEventListener("click",function(){'
|
|
753
|
+
"var t=document.getElementById(btn.dataset.copyTarget);if(!t)return;"
|
|
754
|
+
"navigator.clipboard.writeText(t.textContent).then(function(){"
|
|
755
|
+
'btn.classList.add("copied");'
|
|
756
|
+
'setTimeout(function(){btn.classList.remove("copied")},1500);});});});'
|
|
757
|
+
# Data version input — toggle SQL clause
|
|
758
|
+
"function updateDvClause(inp){"
|
|
759
|
+
"var name=inp.dataset.catalog;"
|
|
760
|
+
"var sql=document.querySelector('.connect-sql[data-catalog=\"'+name+'\"]');"
|
|
761
|
+
"if(!sql)return;"
|
|
762
|
+
'var clause=sql.querySelector(".dv-clause");'
|
|
763
|
+
'var slot=sql.querySelector(".dv-value");'
|
|
764
|
+
"var v=inp.value.trim();"
|
|
765
|
+
"if(v){slot.textContent=v;clause.hidden=false;}"
|
|
766
|
+
"else{clause.hidden=true;}"
|
|
767
|
+
"}"
|
|
768
|
+
'document.querySelectorAll(".dv-input").forEach(function(inp){'
|
|
769
|
+
'inp.addEventListener("input",function(){updateDvClause(inp);updateCupolaHref();});});'
|
|
770
|
+
# Release timeline: clicking a version button fills the matching
|
|
771
|
+
# dv-input and refreshes the SQL clause + Cupola href.
|
|
772
|
+
'document.querySelectorAll(".release-version").forEach(function(btn){'
|
|
773
|
+
'btn.addEventListener("click",function(){'
|
|
774
|
+
"var name=btn.dataset.catalog;"
|
|
775
|
+
"var inp=document.querySelector('.dv-input[data-catalog=\"'+name+'\"]');"
|
|
776
|
+
"if(!inp)return;"
|
|
777
|
+
"inp.value=btn.dataset.version;"
|
|
778
|
+
"updateDvClause(inp);updateCupolaHref();"
|
|
779
|
+
"inp.focus();});});"
|
|
780
|
+
# Tab switching
|
|
781
|
+
'document.querySelectorAll(".catalog-tab").forEach(function(tab){'
|
|
782
|
+
'tab.addEventListener("click",function(){'
|
|
783
|
+
'document.querySelectorAll(".catalog-tab").forEach(function(t){t.classList.remove("active");});'
|
|
784
|
+
'document.querySelectorAll(".catalog-panel").forEach(function(p){p.hidden=true;});'
|
|
785
|
+
'tab.classList.add("active");'
|
|
786
|
+
"var p=document.getElementById(tab.dataset.panel);if(p)p.hidden=false;"
|
|
787
|
+
"updateCupolaHref();});});"
|
|
788
|
+
# Cupola deep-link — reflects active panel + dv input
|
|
789
|
+
"function updateCupolaHref(){"
|
|
790
|
+
'var cb=document.getElementById("cupola-btn");if(!cb)return;'
|
|
791
|
+
'var active=document.querySelector(".catalog-panel:not([hidden])");'
|
|
792
|
+
'var url="https://cupola.query-farm.services/?service="+encodeURIComponent(u);'
|
|
793
|
+
"if(active){"
|
|
794
|
+
'url+="&catalog="+encodeURIComponent(active.dataset.catalog);'
|
|
795
|
+
'var dv=active.querySelector(".dv-input");'
|
|
796
|
+
"if(dv&&dv.value.trim()){"
|
|
797
|
+
'url+="&data_version_spec="+encodeURIComponent(dv.value.trim());'
|
|
798
|
+
"}}"
|
|
799
|
+
"cb.href=url;"
|
|
800
|
+
"}"
|
|
801
|
+
"updateCupolaHref();"
|
|
802
|
+
"});</script>"
|
|
803
|
+
)
|
|
804
|
+
parts.append("</div>") # /connect-box
|
|
805
|
+
return "\n".join(parts)
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _build_settings_section(setting_specs: list) -> str: # type: ignore[type-arg]
|
|
809
|
+
"""Build the settings section HTML."""
|
|
810
|
+
if not setting_specs:
|
|
811
|
+
return ""
|
|
812
|
+
parts: list[str] = [
|
|
813
|
+
"<h2>Settings</h2>",
|
|
814
|
+
"<table><tr><th>Name</th><th>Type</th><th>Default</th><th>Description</th></tr>",
|
|
815
|
+
]
|
|
816
|
+
for spec in setting_specs:
|
|
817
|
+
default_str = _esc(repr(spec.default)) if spec.default is not None else "—"
|
|
818
|
+
parts.append(
|
|
819
|
+
f"<tr><td><code>{_esc(spec.name)}</code></td>"
|
|
820
|
+
f"<td><code>{_esc(str(spec.type))}</code></td>"
|
|
821
|
+
f"<td>{default_str}</td>"
|
|
822
|
+
f"<td>{_esc(spec.desc)}</td></tr>"
|
|
823
|
+
)
|
|
824
|
+
parts.append("</table>")
|
|
825
|
+
return "\n".join(parts)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
# ---------------------------------------------------------------------------
|
|
829
|
+
# Public API
|
|
830
|
+
# ---------------------------------------------------------------------------
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def build_worker_page(
|
|
834
|
+
worker_cls: type[Worker],
|
|
835
|
+
prefix: str,
|
|
836
|
+
*,
|
|
837
|
+
active_catalog: str | None = None,
|
|
838
|
+
requested_data_version: str | None = None,
|
|
839
|
+
) -> bytes:
|
|
840
|
+
"""Render the worker description page HTML.
|
|
841
|
+
|
|
842
|
+
Extracts metadata from the worker class (functions, catalog, settings)
|
|
843
|
+
and generates a complete HTML page.
|
|
844
|
+
|
|
845
|
+
``active_catalog`` and ``requested_data_version`` come from the request
|
|
846
|
+
query string and let the page reflect the user's submitted form state:
|
|
847
|
+
the named catalog tab is opened, the data-version input is pre-filled,
|
|
848
|
+
and the ATTACH SQL bakes the version clause inline.
|
|
849
|
+
|
|
850
|
+
Args:
|
|
851
|
+
worker_cls: The Worker subclass to describe.
|
|
852
|
+
prefix: URL prefix (e.g. ``/api``).
|
|
853
|
+
active_catalog: Catalog name to mark active (from ``?catalog=``).
|
|
854
|
+
requested_data_version: Data version to pre-fill (from
|
|
855
|
+
``?data_version_spec=``) on the active catalog only.
|
|
856
|
+
|
|
857
|
+
Returns:
|
|
858
|
+
UTF-8 encoded HTML bytes.
|
|
859
|
+
|
|
860
|
+
"""
|
|
861
|
+
worker_name = worker_cls.__name__
|
|
862
|
+
worker_doc = ""
|
|
863
|
+
if worker_cls.__doc__:
|
|
864
|
+
worker_doc = worker_cls.__doc__.strip().split("\n")[0]
|
|
865
|
+
|
|
866
|
+
try:
|
|
867
|
+
vgi_version = _pkg_version("vgi-python")
|
|
868
|
+
except Exception:
|
|
869
|
+
vgi_version = "unknown"
|
|
870
|
+
|
|
871
|
+
# Collect sections
|
|
872
|
+
body_parts: list[str] = []
|
|
873
|
+
|
|
874
|
+
# Connect section: per-catalog ATTACH SQL + attach options.
|
|
875
|
+
panels = _collect_catalog_panels(worker_cls)
|
|
876
|
+
|
|
877
|
+
# Pick the catalog to attach against. When the user submitted via Apply
|
|
878
|
+
# we honour ``active_catalog``; otherwise we fall back to the first
|
|
879
|
+
# advertised catalog so the schema/tables list always reflects a real
|
|
880
|
+
# attach (the only way to surface version-dependent content).
|
|
881
|
+
target_catalog = active_catalog or (panels[0].name if panels else None)
|
|
882
|
+
|
|
883
|
+
attach_iface: CatalogInterface | None = None
|
|
884
|
+
attach_opaque_data: AttachOpaqueData | None = None
|
|
885
|
+
attach_error: str | None = None
|
|
886
|
+
if target_catalog is not None:
|
|
887
|
+
attach_iface, attach_opaque_data, raw_error = _attach_for_describe(
|
|
888
|
+
worker_cls, target_catalog, requested_data_version
|
|
889
|
+
)
|
|
890
|
+
# Only surface the error in the UI if the user explicitly requested a
|
|
891
|
+
# version. A failure with no version requested is silent (defaults
|
|
892
|
+
# might just be unsupported under unusual configurations).
|
|
893
|
+
if raw_error is not None and requested_data_version:
|
|
894
|
+
attach_error = raw_error
|
|
895
|
+
|
|
896
|
+
if attach_error is not None:
|
|
897
|
+
body_parts.append(
|
|
898
|
+
'<div class="dv-error" role="alert">'
|
|
899
|
+
"<strong>Cannot attach with <code>data_version_spec</code> "
|
|
900
|
+
f"<code>{_esc(requested_data_version)}</code>:</strong> "
|
|
901
|
+
f"{_esc(attach_error)}"
|
|
902
|
+
"</div>"
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
body_parts.append(
|
|
906
|
+
_build_connect_section(
|
|
907
|
+
panels,
|
|
908
|
+
prefix,
|
|
909
|
+
active_catalog=active_catalog,
|
|
910
|
+
requested_data_version=requested_data_version,
|
|
911
|
+
)
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
# Cupola explore button
|
|
915
|
+
body_parts.append(
|
|
916
|
+
'<a class="cupola-box" id="cupola-btn" href="https://cupola.query-farm.services/"'
|
|
917
|
+
' target="_blank" rel="noopener">'
|
|
918
|
+
'<span class="cupola-icon" aria-hidden="true">'
|
|
919
|
+
'<svg viewBox="0 0 40 40" width="36" height="36" fill="none" stroke="currentColor"'
|
|
920
|
+
' stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">'
|
|
921
|
+
'<circle cx="20" cy="3" r="0.9" fill="currentColor" stroke="none"/>'
|
|
922
|
+
'<line x1="20" y1="3.8" x2="20" y2="6"/>'
|
|
923
|
+
'<path d="M16 11 C 16 7 17 6 20 6 C 23 6 24 7 24 11"/>'
|
|
924
|
+
'<rect x="15.5" y="11" width="9" height="4.5"/>'
|
|
925
|
+
'<path d="M17.3 14.8 V13 a0.9 0.9 0 0 1 1.8 0 V14.8"/>'
|
|
926
|
+
'<path d="M20.9 14.8 V13 a0.9 0.9 0 0 1 1.8 0 V14.8"/>'
|
|
927
|
+
'<path d="M20 15.5 L 10 21 L 6 26"/>'
|
|
928
|
+
'<path d="M20 15.5 L 30 21 L 34 26"/>'
|
|
929
|
+
'<line x1="6" y1="26" x2="34" y2="26"/>'
|
|
930
|
+
'<rect x="6" y="26" width="28" height="12"/>'
|
|
931
|
+
'<rect x="18" y="20.5" width="4" height="3"/>'
|
|
932
|
+
'<rect x="15.5" y="29" width="9" height="9"/>'
|
|
933
|
+
'<line x1="20" y1="29" x2="20" y2="38"/>'
|
|
934
|
+
'<line x1="15.5" y1="29" x2="20" y2="33.5"/>'
|
|
935
|
+
'<line x1="24.5" y1="29" x2="20" y2="33.5"/>'
|
|
936
|
+
"</svg>"
|
|
937
|
+
"</span>"
|
|
938
|
+
'<span class="cupola-text">'
|
|
939
|
+
'<span class="cupola-title">Explore this data in Cupola</span>'
|
|
940
|
+
'<span class="cupola-subtitle">Browse schemas, tables, views, and functions interactively'
|
|
941
|
+
" — no install required.</span>"
|
|
942
|
+
"</span>"
|
|
943
|
+
'<span class="cupola-arrow" aria-hidden="true">'
|
|
944
|
+
'<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor"'
|
|
945
|
+
' stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">'
|
|
946
|
+
'<path d="M5 3l6 5-6 5"/>'
|
|
947
|
+
"</svg>"
|
|
948
|
+
"</span>"
|
|
949
|
+
"</a>"
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
# Settings
|
|
953
|
+
if worker_cls._setting_specs:
|
|
954
|
+
body_parts.append(_build_settings_section(list(worker_cls._setting_specs)))
|
|
955
|
+
|
|
956
|
+
# Schema content. Prefer dynamic enumeration via the attached catalog —
|
|
957
|
+
# that's the only way version-dependent tables (e.g. versioned_tables)
|
|
958
|
+
# show up. Fall back to the static descriptor when the worker has no
|
|
959
|
+
# catalog interface at all (legacy ``Worker.functions`` pattern).
|
|
960
|
+
if attach_iface is not None and attach_opaque_data is not None:
|
|
961
|
+
body_parts.extend(_render_dynamic_schemas(attach_iface, attach_opaque_data))
|
|
962
|
+
elif (catalog := getattr(worker_cls, "catalog", None)) is not None:
|
|
963
|
+
for schema in catalog.schemas:
|
|
964
|
+
body_parts.append(f'<h2 class="schema-heading">{_esc(schema.name)}</h2>')
|
|
965
|
+
if schema.comment:
|
|
966
|
+
body_parts.append(f'<p class="schema-comment">{_esc(schema.comment)}</p>')
|
|
967
|
+
|
|
968
|
+
# Functions grouped by display type
|
|
969
|
+
if schema.functions:
|
|
970
|
+
func_cards: list[tuple[str, str, str]] = []
|
|
971
|
+
for func_cls in schema.functions:
|
|
972
|
+
meta = resolve_metadata(func_cls)
|
|
973
|
+
display_type = _display_function_type(func_cls, meta)
|
|
974
|
+
card_html = _build_function_card(func_cls, meta)
|
|
975
|
+
func_cards.append((display_type, meta.name, card_html))
|
|
976
|
+
|
|
977
|
+
# Sort: scalar, then table, then table-in-out, then alpha
|
|
978
|
+
type_order = {"scalar": 0, "table": 1, "table-in-out": 2, "aggregate": 3}
|
|
979
|
+
func_cards.sort(key=lambda t: (type_order.get(t[0], 99), t[1]))
|
|
980
|
+
|
|
981
|
+
body_parts.append('<div class="section-label">Functions</div>')
|
|
982
|
+
for _dt, _name, card in func_cards:
|
|
983
|
+
body_parts.append(card)
|
|
984
|
+
|
|
985
|
+
# Tables
|
|
986
|
+
if schema.tables:
|
|
987
|
+
body_parts.append('<div class="section-label">Tables</div>')
|
|
988
|
+
for table in schema.tables:
|
|
989
|
+
body_parts.append(_build_table_card(table))
|
|
990
|
+
|
|
991
|
+
# Views
|
|
992
|
+
if schema.views:
|
|
993
|
+
body_parts.append('<div class="section-label">Views</div>')
|
|
994
|
+
for view in schema.views:
|
|
995
|
+
body_parts.append(_build_view_card(view))
|
|
996
|
+
else:
|
|
997
|
+
# Legacy: worker.functions list (no schema grouping)
|
|
998
|
+
functions = getattr(worker_cls, "functions", None) or []
|
|
999
|
+
if functions:
|
|
1000
|
+
func_cards = []
|
|
1001
|
+
for func_cls in functions:
|
|
1002
|
+
meta = resolve_metadata(func_cls)
|
|
1003
|
+
display_type = _display_function_type(func_cls, meta)
|
|
1004
|
+
card_html = _build_function_card(func_cls, meta)
|
|
1005
|
+
func_cards.append((display_type, meta.name, card_html))
|
|
1006
|
+
|
|
1007
|
+
type_order = {"scalar": 0, "table": 1, "table-in-out": 2, "aggregate": 3}
|
|
1008
|
+
func_cards.sort(key=lambda t: (type_order.get(t[0], 99), t[1]))
|
|
1009
|
+
|
|
1010
|
+
body_parts.append('<div class="section-label">Functions</div>')
|
|
1011
|
+
for _dt, _name, card in func_cards:
|
|
1012
|
+
body_parts.append(card)
|
|
1013
|
+
|
|
1014
|
+
body_html = "\n".join(body_parts)
|
|
1015
|
+
|
|
1016
|
+
page = _PAGE_TEMPLATE.format(
|
|
1017
|
+
worker_name=_esc(worker_name),
|
|
1018
|
+
worker_doc=_esc(worker_doc),
|
|
1019
|
+
vgi_version=_esc(vgi_version),
|
|
1020
|
+
prefix=_esc(prefix),
|
|
1021
|
+
body=body_html,
|
|
1022
|
+
)
|
|
1023
|
+
return page.encode("utf-8")
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
class WorkerPageResource:
|
|
1027
|
+
"""Falcon resource serving the worker description page.
|
|
1028
|
+
|
|
1029
|
+
Renders per request so the page can reflect ``?catalog=`` and
|
|
1030
|
+
``?data_version_spec=`` query params submitted via the Apply form.
|
|
1031
|
+
Optional ``body_transform`` is applied to the rendered bytes (used by
|
|
1032
|
+
``vgi-serve`` to inject the OAuth/PKCE user-info script).
|
|
1033
|
+
"""
|
|
1034
|
+
|
|
1035
|
+
__slots__ = ("_body_transform", "_prefix", "_worker_cls")
|
|
1036
|
+
|
|
1037
|
+
def __init__(
|
|
1038
|
+
self,
|
|
1039
|
+
worker_cls: type[Worker],
|
|
1040
|
+
prefix: str,
|
|
1041
|
+
body_transform: Callable[[bytes], bytes] | None = None,
|
|
1042
|
+
) -> None:
|
|
1043
|
+
"""Store the worker class + prefix + optional post-render transform."""
|
|
1044
|
+
self._worker_cls = worker_cls
|
|
1045
|
+
self._prefix = prefix
|
|
1046
|
+
self._body_transform = body_transform
|
|
1047
|
+
|
|
1048
|
+
def on_get(self, req: falcon.Request, resp: falcon.Response) -> None:
|
|
1049
|
+
"""Render the worker description page, honouring query params."""
|
|
1050
|
+
active_catalog = req.get_param("catalog") if req is not None else None
|
|
1051
|
+
requested_dv = req.get_param("data_version_spec") if req is not None else None
|
|
1052
|
+
body = build_worker_page(
|
|
1053
|
+
self._worker_cls,
|
|
1054
|
+
self._prefix,
|
|
1055
|
+
active_catalog=active_catalog,
|
|
1056
|
+
requested_data_version=requested_dv,
|
|
1057
|
+
)
|
|
1058
|
+
if self._body_transform is not None:
|
|
1059
|
+
body = self._body_transform(body)
|
|
1060
|
+
resp.content_type = "text/html; charset=utf-8"
|
|
1061
|
+
resp.data = body
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
# ---------------------------------------------------------------------------
|
|
1065
|
+
# Page template
|
|
1066
|
+
# ---------------------------------------------------------------------------
|
|
1067
|
+
|
|
1068
|
+
_PAGE_TEMPLATE = (
|
|
1069
|
+
"""\
|
|
1070
|
+
<!DOCTYPE html>
|
|
1071
|
+
<html lang="en">
|
|
1072
|
+
<head>
|
|
1073
|
+
<meta charset="utf-8">
|
|
1074
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1075
|
+
<title>{worker_name} — VGI Worker</title>
|
|
1076
|
+
"""
|
|
1077
|
+
+ _FONT_IMPORTS
|
|
1078
|
+
+ """
|
|
1079
|
+
<style>
|
|
1080
|
+
body {{ font-family: 'Inter', system-ui, -apple-system, sans-serif; max-width: 900px;
|
|
1081
|
+
margin: 0 auto; padding: 40px 20px 0; color: #2c2c1e; background: #faf8f0; }}
|
|
1082
|
+
.header {{ text-align: center; margin-bottom: 40px; }}
|
|
1083
|
+
.header .logo img {{ width: 80px; height: 80px; border-radius: 50%;
|
|
1084
|
+
box-shadow: 0 3px 16px rgba(0,0,0,0.10); }}
|
|
1085
|
+
.header h1 {{ margin-bottom: 4px; color: #2d5016; font-weight: 700; }}
|
|
1086
|
+
.header .subtitle {{ color: #6b6b5a; font-size: 1.1em; margin-top: 0; }}
|
|
1087
|
+
.header .meta {{ color: #6b6b5a; font-size: 0.9em; }}
|
|
1088
|
+
.header .meta a {{ color: #2d5016; font-weight: 600; }}
|
|
1089
|
+
.header .meta a:hover {{ color: #4a7c23; }}
|
|
1090
|
+
code {{ font-family: 'JetBrains Mono', monospace; background: #f0ece0;
|
|
1091
|
+
padding: 2px 6px; border-radius: 3px; font-size: 0.85em; color: #2c2c1e; }}
|
|
1092
|
+
pre {{ background: #f0ece0; padding: 12px 16px; border-radius: 6px;
|
|
1093
|
+
overflow-x: auto; font-size: 0.85em; }}
|
|
1094
|
+
pre code {{ background: none; padding: 0; }}
|
|
1095
|
+
a {{ color: #2d5016; text-decoration: none; }}
|
|
1096
|
+
a:hover {{ color: #4a7c23; }}
|
|
1097
|
+
h2 {{ color: #2d5016; font-weight: 700; margin-top: 36px; margin-bottom: 16px;
|
|
1098
|
+
border-bottom: 2px solid #f0ece0; padding-bottom: 8px; }}
|
|
1099
|
+
.schema-heading {{ font-size: 1.3em; }}
|
|
1100
|
+
.schema-comment {{ color: #6b6b5a; margin-top: -8px; margin-bottom: 16px; }}
|
|
1101
|
+
.card {{ border: 1px solid #f0ece0; border-radius: 8px; padding: 20px;
|
|
1102
|
+
margin-bottom: 16px; background: #fff; }}
|
|
1103
|
+
.card:hover {{ border-color: #c8a43a; }}
|
|
1104
|
+
.card-header {{ display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
|
|
1105
|
+
flex-wrap: wrap; }}
|
|
1106
|
+
.method-name {{ font-family: 'JetBrains Mono', monospace; font-size: 1.1em;
|
|
1107
|
+
font-weight: 600; color: #2d5016; }}
|
|
1108
|
+
.badge {{ display: inline-block; padding: 2px 8px; border-radius: 4px;
|
|
1109
|
+
font-size: 0.75em; font-weight: 600; text-transform: uppercase;
|
|
1110
|
+
letter-spacing: 0.03em; }}
|
|
1111
|
+
.badge-scalar {{ background: #e8f5e0; color: #2d5016; }}
|
|
1112
|
+
.badge-table {{ background: #e0ecf5; color: #1a4a6b; }}
|
|
1113
|
+
.badge-table-in-out {{ background: #f5e6f0; color: #6b234a; }}
|
|
1114
|
+
.badge-aggregate {{ background: #f5eee0; color: #6b4423; }}
|
|
1115
|
+
.badge-stability {{ background: #fff3cd; color: #856404; }}
|
|
1116
|
+
.badge-table-obj {{ background: #e0f0f5; color: #1a5a6b; }}
|
|
1117
|
+
.badge-view {{ background: #f0e8f5; color: #4a236b; }}
|
|
1118
|
+
.mini-badge {{ display: inline-block; padding: 1px 6px; border-radius: 3px;
|
|
1119
|
+
font-size: 0.7em; font-weight: 600; vertical-align: middle;
|
|
1120
|
+
margin-left: 4px; }}
|
|
1121
|
+
.mini-const {{ background: #e8f5e0; color: #2d5016; }}
|
|
1122
|
+
.mini-varargs {{ background: #f5eee0; color: #6b4423; }}
|
|
1123
|
+
.mini-table-input {{ background: #e0ecf5; color: #1a4a6b; }}
|
|
1124
|
+
.mini-func-backed {{ background: #f0ece0; color: #6b6b5a; }}
|
|
1125
|
+
.connect-box {{ border: 1px solid #e0dcd0; border-radius: 8px; padding: 16px 20px;
|
|
1126
|
+
margin-bottom: 32px; background: #fff; }}
|
|
1127
|
+
.connect-box pre {{ margin: 8px 0 0; }}
|
|
1128
|
+
.connect-label {{ font-size: 0.8em; font-weight: 600; text-transform: uppercase;
|
|
1129
|
+
letter-spacing: 0.05em; color: #6b6b5a;
|
|
1130
|
+
display: flex; align-items: center; gap: 8px; }}
|
|
1131
|
+
.copy-btn {{ background: none; border: 1px solid #e0dcd0; border-radius: 4px;
|
|
1132
|
+
padding: 3px 5px; cursor: pointer; color: #6b6b5a;
|
|
1133
|
+
transition: all 0.15s ease; display: inline-flex; align-items: center; }}
|
|
1134
|
+
.copy-btn:hover {{ color: #2d5016; border-color: #4a7c23; }}
|
|
1135
|
+
.copy-btn.copied {{ color: #4a7c23; border-color: #4a7c23; }}
|
|
1136
|
+
.connect-url {{ color: #4a7c23; }}
|
|
1137
|
+
.catalog-tabs {{ display: flex; gap: 4px; margin-bottom: 12px;
|
|
1138
|
+
border-bottom: 1px solid #e0dcd0; flex-wrap: wrap; }}
|
|
1139
|
+
.catalog-tab {{ background: none; border: none; padding: 8px 14px;
|
|
1140
|
+
margin-bottom: -1px; cursor: pointer; font-family: inherit;
|
|
1141
|
+
font-size: 0.9em; font-weight: 600; color: #6b6b5a;
|
|
1142
|
+
border-bottom: 2px solid transparent;
|
|
1143
|
+
transition: color 0.15s, border-color 0.15s; }}
|
|
1144
|
+
.catalog-tab:hover {{ color: #2d5016; }}
|
|
1145
|
+
.catalog-tab.active {{ color: #2d5016; border-bottom-color: #2d5016; }}
|
|
1146
|
+
.catalog-panel[hidden] {{ display: none; }}
|
|
1147
|
+
.catalog-comment {{ color: #6b6b5a; margin: 0 0 8px; font-size: 0.9em; }}
|
|
1148
|
+
.impl-row {{ display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
|
1149
|
+
margin: 0 0 10px; font-size: 0.85em; }}
|
|
1150
|
+
.impl-chip {{ display: inline-block; font-size: 0.85em; color: #6b6b5a;
|
|
1151
|
+
background: #f0ece0; padding: 3px 10px; border-radius: 999px; }}
|
|
1152
|
+
.impl-chip code {{ background: none; padding: 0; color: #2d5016; font-weight: 600; }}
|
|
1153
|
+
.source-link {{ color: #2d5016; font-weight: 600; text-decoration: none; }}
|
|
1154
|
+
.source-link:hover {{ color: #4a7c23; text-decoration: underline; }}
|
|
1155
|
+
.dv-row {{ display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
|
1156
|
+
margin: 0 0 10px; font-size: 0.9em; }}
|
|
1157
|
+
.dv-label {{ font-weight: 600; color: #2c2c1e; }}
|
|
1158
|
+
.dv-input {{ font-family: 'JetBrains Mono', monospace; font-size: 0.9em;
|
|
1159
|
+
padding: 4px 8px; border: 1px solid #e0dcd0; border-radius: 4px;
|
|
1160
|
+
background: #faf8f0; color: #2c2c1e; min-width: 140px; }}
|
|
1161
|
+
.dv-input:focus {{ outline: none; border-color: #4a7c23; }}
|
|
1162
|
+
.dv-apply {{ font-family: inherit; font-size: 0.85em; font-weight: 600;
|
|
1163
|
+
background: #2d5016; color: #faf8f0; border: 1px solid #2d5016;
|
|
1164
|
+
padding: 5px 14px; border-radius: 4px; cursor: pointer;
|
|
1165
|
+
transition: background 0.15s, border-color 0.15s; }}
|
|
1166
|
+
.dv-apply:hover {{ background: #4a7c23; border-color: #4a7c23; }}
|
|
1167
|
+
.dv-form {{ margin: 0 0 10px; }}
|
|
1168
|
+
.dv-hint {{ color: #6b6b5a; font-size: 0.85em; }}
|
|
1169
|
+
.dv-hint code {{ color: #2d5016; }}
|
|
1170
|
+
.dv-error {{ background: #fdecea; border: 1px solid #c33; color: #6b1414;
|
|
1171
|
+
border-radius: 6px; padding: 12px 16px; margin-bottom: 18px;
|
|
1172
|
+
font-size: 0.95em; }}
|
|
1173
|
+
.dv-error code {{ background: rgba(195,51,51,0.10); color: #6b1414; }}
|
|
1174
|
+
.dv-error strong {{ font-weight: 700; }}
|
|
1175
|
+
.release-timeline {{ margin: 0 0 16px; }}
|
|
1176
|
+
.release-list {{ list-style: none; padding: 0; margin: 6px 0 0;
|
|
1177
|
+
border: 1px solid #e0dcd0; border-radius: 6px;
|
|
1178
|
+
background: #fff; }}
|
|
1179
|
+
.release-list li {{ display: flex; align-items: center; gap: 10px;
|
|
1180
|
+
padding: 8px 12px; border-bottom: 1px solid #f0ece0;
|
|
1181
|
+
font-size: 0.9em; flex-wrap: wrap; }}
|
|
1182
|
+
.release-list li:last-child {{ border-bottom: none; }}
|
|
1183
|
+
.release-version {{ font-family: 'JetBrains Mono', monospace; font-weight: 600;
|
|
1184
|
+
color: #2d5016; background: #f0ece0; border: 1px solid #e0dcd0;
|
|
1185
|
+
padding: 3px 10px; border-radius: 4px; cursor: pointer;
|
|
1186
|
+
font-size: 0.9em; transition: background 0.15s, color 0.15s; }}
|
|
1187
|
+
.release-version:hover {{ background: #2d5016; color: #faf8f0; }}
|
|
1188
|
+
.release-date {{ color: #6b6b5a; font-size: 0.85em;
|
|
1189
|
+
font-family: 'JetBrains Mono', monospace; }}
|
|
1190
|
+
.release-summary {{ color: #2c2c1e; flex: 1 1 auto; min-width: 180px; }}
|
|
1191
|
+
.release-details {{ color: #2d5016; font-weight: 600; font-size: 0.85em;
|
|
1192
|
+
text-decoration: none; }}
|
|
1193
|
+
.release-details:hover {{ text-decoration: underline; }}
|
|
1194
|
+
.cupola-box {{ display: flex; align-items: center; gap: 16px;
|
|
1195
|
+
border: 1px solid #2d5016; border-radius: 8px; padding: 16px 20px;
|
|
1196
|
+
margin-bottom: 32px; background: #2d5016; color: #faf8f0;
|
|
1197
|
+
text-decoration: none; transition: background 0.15s ease,
|
|
1198
|
+
border-color 0.15s ease, transform 0.15s ease; }}
|
|
1199
|
+
.cupola-box:hover {{ background: #3d6a1f; border-color: #4a7c23;
|
|
1200
|
+
color: #faf8f0; transform: translateY(-1px); }}
|
|
1201
|
+
.cupola-icon {{ flex: 0 0 auto; display: inline-flex; align-items: center;
|
|
1202
|
+
justify-content: center; width: 56px; height: 56px;
|
|
1203
|
+
border-radius: 50%; background: rgba(255,255,255,0.10);
|
|
1204
|
+
color: #faf8f0; }}
|
|
1205
|
+
.cupola-text {{ flex: 1 1 auto; display: flex; flex-direction: column; gap: 2px; }}
|
|
1206
|
+
.cupola-title {{ font-size: 1.05em; font-weight: 700; letter-spacing: 0.01em; }}
|
|
1207
|
+
.cupola-subtitle {{ font-size: 0.88em; color: #cfd8be; line-height: 1.4; }}
|
|
1208
|
+
.cupola-arrow {{ flex: 0 0 auto; color: #cfd8be; transition: transform 0.15s ease; }}
|
|
1209
|
+
.cupola-box:hover .cupola-arrow {{ transform: translateX(3px); color: #faf8f0; }}
|
|
1210
|
+
.docstring {{ color: #6b6b5a; margin-bottom: 12px; line-height: 1.5; }}
|
|
1211
|
+
.example-desc {{ color: #6b6b5a; font-size: 0.9em; margin-bottom: 4px; }}
|
|
1212
|
+
table {{ width: 100%; border-collapse: collapse; font-size: 0.9em; }}
|
|
1213
|
+
th {{ text-align: left; padding: 8px 10px; background: #f0ece0; color: #2c2c1e;
|
|
1214
|
+
font-weight: 600; border-bottom: 2px solid #e0dcd0; }}
|
|
1215
|
+
td {{ padding: 8px 10px; border-bottom: 1px solid #f0ece0; }}
|
|
1216
|
+
td code {{ font-size: 0.85em; }}
|
|
1217
|
+
.no-params {{ color: #6b6b5a; font-style: italic; font-size: 0.9em; }}
|
|
1218
|
+
.section-label {{ font-size: 0.8em; font-weight: 600; text-transform: uppercase;
|
|
1219
|
+
letter-spacing: 0.05em; color: #6b6b5a; margin-top: 14px;
|
|
1220
|
+
margin-bottom: 6px; }}
|
|
1221
|
+
.caps {{ display: flex; gap: 6px; flex-wrap: wrap; }}
|
|
1222
|
+
.cap-tag {{ display: inline-block; padding: 2px 8px; border-radius: 4px;
|
|
1223
|
+
font-size: 0.8em; background: #f0ece0; color: #6b6b5a; }}
|
|
1224
|
+
footer {{ text-align: center; margin-top: 48px; padding: 20px 0;
|
|
1225
|
+
border-top: 1px solid #f0ece0; color: #6b6b5a; font-size: 0.85em; }}
|
|
1226
|
+
footer a {{ color: #2d5016; font-weight: 600; }}
|
|
1227
|
+
footer a:hover {{ color: #4a7c23; }}
|
|
1228
|
+
</style>
|
|
1229
|
+
</head>
|
|
1230
|
+
<body>
|
|
1231
|
+
<div class="header">
|
|
1232
|
+
<div class="logo">
|
|
1233
|
+
<img src="https://vgi-rpc-python.query.farm/assets/logo-hero.png" alt="vgi logo">
|
|
1234
|
+
</div>
|
|
1235
|
+
<h1>{worker_name}</h1>
|
|
1236
|
+
<p class="subtitle">VGI Worker</p>
|
|
1237
|
+
<p class="meta">{worker_doc}</p>
|
|
1238
|
+
<p class="meta"><code>vgi</code> v{vgi_version}</p>
|
|
1239
|
+
</div>
|
|
1240
|
+
{body}
|
|
1241
|
+
<footer>
|
|
1242
|
+
<a href="{prefix}">vgi-rpc endpoint</a>
|
|
1243
|
+
·
|
|
1244
|
+
<a href="{prefix}/describe">vgi-rpc API reference</a>
|
|
1245
|
+
·
|
|
1246
|
+
<a href="https://vgi-rpc.query.farm">About <code>vgi-rpc</code></a>
|
|
1247
|
+
·
|
|
1248
|
+
© 2026 🚜 <a href="https://query.farm">Query.Farm LLC</a>
|
|
1249
|
+
</footer>
|
|
1250
|
+
</body>
|
|
1251
|
+
</html>"""
|
|
1252
|
+
)
|