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.
Files changed (124) hide show
  1. vgi/__init__.py +152 -0
  2. vgi/_duckdb.py +62 -0
  3. vgi/_storage_profile.py +132 -0
  4. vgi/_test_fixtures/__init__.py +20 -0
  5. vgi/_test_fixtures/accumulate/__init__.py +19 -0
  6. vgi/_test_fixtures/accumulate/worker.py +762 -0
  7. vgi/_test_fixtures/aggregate/__init__.py +62 -0
  8. vgi/_test_fixtures/aggregate/_common.py +21 -0
  9. vgi/_test_fixtures/aggregate/basic.py +232 -0
  10. vgi/_test_fixtures/aggregate/dynamic.py +409 -0
  11. vgi/_test_fixtures/aggregate/generic.py +86 -0
  12. vgi/_test_fixtures/aggregate/listagg.py +71 -0
  13. vgi/_test_fixtures/aggregate/percentile.py +107 -0
  14. vgi/_test_fixtures/aggregate/streaming.py +192 -0
  15. vgi/_test_fixtures/aggregate/varargs.py +75 -0
  16. vgi/_test_fixtures/aggregate/window.py +380 -0
  17. vgi/_test_fixtures/attach_options.py +308 -0
  18. vgi/_test_fixtures/bad_protocol.py +62 -0
  19. vgi/_test_fixtures/cancellable.py +336 -0
  20. vgi/_test_fixtures/catalog.py +813 -0
  21. vgi/_test_fixtures/http_server.py +394 -0
  22. vgi/_test_fixtures/nest_tensor.py +614 -0
  23. vgi/_test_fixtures/orchard_catalog.py +47 -0
  24. vgi/_test_fixtures/projection_repro/__init__.py +6 -0
  25. vgi/_test_fixtures/projection_repro/worker.py +454 -0
  26. vgi/_test_fixtures/scalar/__init__.py +116 -0
  27. vgi/_test_fixtures/scalar/_common.py +69 -0
  28. vgi/_test_fixtures/scalar/arithmetic.py +321 -0
  29. vgi/_test_fixtures/scalar/binary.py +120 -0
  30. vgi/_test_fixtures/scalar/formatting.py +176 -0
  31. vgi/_test_fixtures/scalar/geo.py +300 -0
  32. vgi/_test_fixtures/scalar/null_handling.py +107 -0
  33. vgi/_test_fixtures/scalar/random_demo.py +171 -0
  34. vgi/_test_fixtures/scalar/settings_secrets.py +102 -0
  35. vgi/_test_fixtures/scalar/type_info.py +219 -0
  36. vgi/_test_fixtures/schema_reconcile/__init__.py +29 -0
  37. vgi/_test_fixtures/schema_reconcile/worker.py +653 -0
  38. vgi/_test_fixtures/simple_writable.py +793 -0
  39. vgi/_test_fixtures/table/__init__.py +221 -0
  40. vgi/_test_fixtures/table/_common.py +162 -0
  41. vgi/_test_fixtures/table/batch_index.py +283 -0
  42. vgi/_test_fixtures/table/batch_index_broken.py +200 -0
  43. vgi/_test_fixtures/table/catalog_scans.py +162 -0
  44. vgi/_test_fixtures/table/filters.py +1005 -0
  45. vgi/_test_fixtures/table/late_materialization.py +249 -0
  46. vgi/_test_fixtures/table/make_series.py +273 -0
  47. vgi/_test_fixtures/table/misc.py +499 -0
  48. vgi/_test_fixtures/table/order_modes.py +164 -0
  49. vgi/_test_fixtures/table/pairs.py +437 -0
  50. vgi/_test_fixtures/table/partition_columns.py +472 -0
  51. vgi/_test_fixtures/table/partition_columns_broken.py +304 -0
  52. vgi/_test_fixtures/table/profiling_example.py +195 -0
  53. vgi/_test_fixtures/table/required_filters.py +234 -0
  54. vgi/_test_fixtures/table/sequence.py +710 -0
  55. vgi/_test_fixtures/table/settings.py +426 -0
  56. vgi/_test_fixtures/table/transaction_storage.py +162 -0
  57. vgi/_test_fixtures/table/tt_pushdown.py +191 -0
  58. vgi/_test_fixtures/table/versioned.py +230 -0
  59. vgi/_test_fixtures/table_in_out.py +1392 -0
  60. vgi/_test_fixtures/versioned.py +155 -0
  61. vgi/_test_fixtures/versioned_tables.py +595 -0
  62. vgi/_test_fixtures/worker.py +1631 -0
  63. vgi/_test_fixtures/writable/__init__.py +8 -0
  64. vgi/_test_fixtures/writable/generic.py +236 -0
  65. vgi/_test_fixtures/writable/table.py +149 -0
  66. vgi/_test_fixtures/writable/worker.py +1148 -0
  67. vgi/aggregate_function.py +607 -0
  68. vgi/argument_spec.py +472 -0
  69. vgi/arguments.py +1747 -0
  70. vgi/auth.py +55 -0
  71. vgi/catalog/__init__.py +88 -0
  72. vgi/catalog/attach_option.py +206 -0
  73. vgi/catalog/catalog_interface.py +2767 -0
  74. vgi/catalog/descriptors.py +870 -0
  75. vgi/catalog/duckdb_statistics.py +377 -0
  76. vgi/catalog/secret_type.py +96 -0
  77. vgi/catalog/setting.py +253 -0
  78. vgi/catalog/storage.py +372 -0
  79. vgi/client/__init__.py +67 -0
  80. vgi/client/catalog_mixin.py +1251 -0
  81. vgi/client/cli.py +582 -0
  82. vgi/client/cli_catalog.py +182 -0
  83. vgi/client/cli_schema.py +270 -0
  84. vgi/client/cli_table.py +907 -0
  85. vgi/client/cli_transaction.py +97 -0
  86. vgi/client/cli_utils.py +441 -0
  87. vgi/client/cli_view.py +303 -0
  88. vgi/client/client.py +2183 -0
  89. vgi/exceptions.py +205 -0
  90. vgi/function.py +245 -0
  91. vgi/function_storage.py +1636 -0
  92. vgi/function_storage_azure_sql.py +922 -0
  93. vgi/function_storage_cf_do.py +740 -0
  94. vgi/http/__init__.py +25 -0
  95. vgi/http/demo_storage.py +212 -0
  96. vgi/http/worker_page.py +1252 -0
  97. vgi/invocation.py +154 -0
  98. vgi/logging_config.py +93 -0
  99. vgi/meta_worker.py +661 -0
  100. vgi/metadata.py +1403 -0
  101. vgi/otel.py +406 -0
  102. vgi/protocol.py +2418 -0
  103. vgi/protocol_version.txt +1 -0
  104. vgi/py.typed +0 -0
  105. vgi/scalar_function.py +1211 -0
  106. vgi/schema_utils.py +234 -0
  107. vgi/secret_protocol.py +124 -0
  108. vgi/secret_service.py +238 -0
  109. vgi/serve.py +769 -0
  110. vgi/table_buffering_function.py +443 -0
  111. vgi/table_filter_pushdown.py +1528 -0
  112. vgi/table_function.py +1130 -0
  113. vgi/table_in_out_function.py +383 -0
  114. vgi/transactor/__init__.py +24 -0
  115. vgi/transactor/_duckdb_compat.py +27 -0
  116. vgi/transactor/client.py +137 -0
  117. vgi/transactor/protocol.py +149 -0
  118. vgi/transactor/server.py +740 -0
  119. vgi/worker.py +4761 -0
  120. vgi_python-0.8.0.dist-info/METADATA +735 -0
  121. vgi_python-0.8.0.dist-info/RECORD +124 -0
  122. vgi_python-0.8.0.dist-info/WHEEL +4 -0
  123. vgi_python-0.8.0.dist-info/entry_points.txt +5 -0
  124. vgi_python-0.8.0.dist-info/licenses/LICENSE +134 -0
@@ -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 "&mdash;"
118
+ default_str = _esc(repr(p.default)) if p.default is not None else "&mdash;"
119
+ desc_str = _esc(p.description) if p.description else "&mdash;"
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 "&mdash;"
572
+ desc_str = _esc(spec.desc) if spec.desc else "&mdash;"
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 &rarr;</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 &rarr;</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 "&mdash;"
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
+ " &mdash; 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} &mdash; 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
+ &middot;
1244
+ <a href="{prefix}/describe">vgi-rpc API reference</a>
1245
+ &middot;
1246
+ <a href="https://vgi-rpc.query.farm">About <code>vgi-rpc</code></a>
1247
+ &middot;
1248
+ &copy; 2026 &#x1F69C; <a href="https://query.farm">Query.Farm LLC</a>
1249
+ </footer>
1250
+ </body>
1251
+ </html>"""
1252
+ )