Skip to content

Commit b11f2c1

Browse files
committed
fix: update browser catalog discovery; release 0.37.2
1 parent 9afbf96 commit b11f2c1

6 files changed

Lines changed: 364 additions & 33 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ jobs:
3535
- name: Set up Python ${{ matrix.python-version }}
3636
run: uv python install ${{ matrix.python-version }}
3737

38+
- name: Install Bun for browser discovery regression tests
39+
uses: oven-sh/setup-bun@v2
40+
with:
41+
bun-version: "1.3.14"
42+
3843
- name: Install dependencies
3944
run: uv sync --all-extras
4045

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "vgi-python"
3-
version = "0.37.1"
3+
version = "0.37.2"
44
description = "Vector Gateway Interface - Connect DuckDB to external programs via Apache Arrow"
55
readme = "README.md"
66
keywords = [

‎tests/test_landing_discovery.py‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Copyright 2025, 2026 Query Farm LLC - https://query.farm
2+
3+
"""Exercise the vendored browser client against the Python HTTP server."""
4+
5+
from __future__ import annotations
6+
7+
import json
8+
import shutil
9+
import subprocess
10+
import threading
11+
from pathlib import Path
12+
from wsgiref.simple_server import make_server
13+
14+
import pyarrow as pa
15+
import pytest
16+
17+
from vgi.scalar_function import ScalarFunction
18+
from vgi.serve import create_app
19+
from vgi.worker import Worker
20+
21+
22+
class _Double(ScalarFunction):
23+
class Meta:
24+
name = "double"
25+
26+
def compute(self, x: pa.Int64Array) -> pa.Int64Array:
27+
return pa.compute.multiply(x, 2)
28+
29+
30+
class _LandingWorker(Worker):
31+
functions = [_Double]
32+
33+
34+
@pytest.mark.skipif(shutil.which("bun") is None, reason="Bun is required to execute the shipped browser client")
35+
def test_served_browser_client_discovers_catalog(tmp_path: Path) -> None:
36+
"""A successful asset GET is insufficient: the bundle must speak the current RPC protocol."""
37+
script = tmp_path / "discover.mjs"
38+
script.write_text("""
39+
const url = process.argv[2];
40+
let rpc;
41+
try {
42+
const response = await fetch(url + '/vgi-client.js');
43+
if (!response.ok) throw new Error('bundle HTTP ' + response.status);
44+
const path = new URL('./client.mjs', import.meta.url);
45+
await Bun.write(path, await response.arrayBuffer());
46+
const browser = await import(path.href);
47+
rpc = browser.httpConnect(url);
48+
const client = new browser.VgiClient(rpc);
49+
const infos = await client.catalogsInfo();
50+
if (!infos.length) throw new Error('No catalogs discovered');
51+
const attached = await client.catalogAttach(infos[0].name);
52+
const schemas = await client.schemas(attached.attach_opaque_data);
53+
const names = [];
54+
for (const schema of schemas) {
55+
const functions = await client.schemaContentsFunctions(attached.attach_opaque_data, schema.path, 'SCALAR_FUNCTION');
56+
names.push(...functions.map(f => f.name));
57+
}
58+
console.log(JSON.stringify(names));
59+
} catch (error) {
60+
console.error(error.stack || String(error));
61+
process.exitCode = 1;
62+
} finally {
63+
rpc?.close();
64+
}
65+
""")
66+
app = create_app(_LandingWorker, signing_key=b"landing-discovery-test-key")
67+
with make_server("127.0.0.1", 0, app) as server:
68+
thread = threading.Thread(target=server.serve_forever, daemon=True)
69+
thread.start()
70+
try:
71+
result = subprocess.run(
72+
["bun", str(script), f"http://127.0.0.1:{server.server_port}"],
73+
capture_output=True,
74+
text=True,
75+
timeout=30,
76+
)
77+
assert result.returncode == 0, result.stderr
78+
assert "double" in json.loads(result.stdout)
79+
finally:
80+
server.shutdown()
81+
thread.join(timeout=5)

‎uv.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎vgi/http/landing.html‎

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@
280280
</style>
281281
</head>
282282
<body>
283-
<!-- vgi-landing-asset v4 -->
283+
<!-- vgi-landing-asset v5 -->
284284

285285
<a class="skip" href="#contents">Skip to contents</a>
286286

@@ -362,7 +362,7 @@ <h1 id="worker-name">—</h1>
362362
import {
363363
VgiClient, httpConnect, deserializeAttachOptionSpecs, deserializeSchema,
364364
schemaToArgumentSpecs, TypeId
365-
} from './vgi-client.js';
365+
} from './vgi-client.js?v=5';
366366

367367
// ── Catalog metadata, read straight off the VGI wire protocol ────────────────────
368368
// The page speaks the protocol the DuckDB extension speaks, through the JS
@@ -417,7 +417,7 @@ <h1 id="worker-name">—</h1>
417417
// JSON endpoint — the difference is where the bytes come from.
418418
const columnCache = new Map();
419419
async function fetchColumns(catName, schema, table){
420-
const key = catName+'
420+
const key = JSON.stringify([catName, schema, table]);
421421
if (columnCache.has(key)) return columnCache.get(key);
422422
const cat = describe.catalogs.find(c=>c.name===catName);
423423
if (!cat || !cat._aod) return {columns:[]};
@@ -480,13 +480,13 @@ <h1 id="worker-name">—</h1>
480480
async function schemaEntry(aod, si){
481481
// A VGI worker commonly exposes its callable surface as declarative macros,
482482
// so they fold into the same scalar/table buckets as functions.
483-
const macros = (kind)=> client.schemaContentsMacros(aod, si.name, kind).catch(()=>[]);
483+
const macros = (kind)=> client.schemaContentsMacros(aod, si.path, kind).catch(()=>[]);
484484
const [tablesRaw, viewsRaw, scalarFns, tableFns, aggFns, scalarMacros, tableMacros] = await Promise.all([
485-
client.schemaContentsTables(aod, si.name),
486-
client.schemaContentsViews(aod, si.name),
487-
client.schemaContentsFunctions(aod, si.name, 'SCALAR_FUNCTION'),
488-
client.schemaContentsFunctions(aod, si.name, 'TABLE_FUNCTION'),
489-
client.schemaContentsFunctions(aod, si.name, 'AGGREGATE_FUNCTION'),
485+
client.schemaContentsTables(aod, si.path),
486+
client.schemaContentsViews(aod, si.path),
487+
client.schemaContentsFunctions(aod, si.path, 'SCALAR_FUNCTION'),
488+
client.schemaContentsFunctions(aod, si.path, 'TABLE_FUNCTION'),
489+
client.schemaContentsFunctions(aod, si.path, 'AGGREGATE_FUNCTION'),
490490
macros('SCALAR_MACRO'),
491491
macros('TABLE_MACRO')
492492
]);
@@ -522,7 +522,7 @@ <h1 id="worker-name">—</h1>
522522
doc: m.comment || '', args: (m.parameters||[]).map(p=>({name:p}))
523523
})));
524524
functions.sort((a,b)=> a.type===b.type ? byName(a,b) : (a.type<b.type?-1:1));
525-
const entry = {name: si.name, tables, views, functions};
525+
const entry = {name: si.path.join('.'), path: si.path, tables, views, functions};
526526
if (si.comment) entry.doc = si.comment;
527527
return entry;
528528
}
@@ -563,7 +563,7 @@ <h1 id="worker-name">—</h1>
563563
cat.tags = mapTags(attach.tags || {});
564564

565565
const schemaInfos = (await client.schemas(cat._aod)).slice()
566-
.sort((a,b)=>a.name<b.name?-1:a.name>b.name?1:0);
566+
.sort((a,b)=>a.path.join('.').localeCompare(b.path.join('.')));
567567
cat.schemas = await Promise.all(schemaInfos.map(si=>schemaEntry(cat._aod, si)));
568568
// `s.functions` holds both, so macros are counted apart rather than inflating
569569
// the function count — vgi-open-meteo reads "13 functions · 6 macros", not
@@ -754,7 +754,7 @@ <h1 id="worker-name">—</h1>
754754
// unsupported version) has no schemas to draw an example from.
755755
const s = c.schemas[0]; if (!s) return 'main.my_table';
756756
const t = (s.tables[0]||s.views[0]||{name:'my_table'});
757-
return s.name+'.'+t.name;
757+
return s.path.concat(t.name).map(n=>/^[a-z_][a-z0-9_]*$/i.test(n) ? n : '"'+n.replace(/"/g,'""')+'"').join('.');
758758
}
759759
function connectCode(c, pinned, lang){
760760
const q=c.name, ex=exampleTable(c), P=attachParen(c,pinned);
@@ -843,7 +843,7 @@ <h1 id="worker-name">—</h1>
843843
function tableRow(catName, s, t, kind){ // kind: 'table' | 'view'
844844
const iconName = kind==='view' ? 'view' : 'table';
845845
const kcls = kind==='view' ? ' view k-view' : '';
846-
return '<details class="tbl'+kcls+'" data-name="'+esc(t.name.toLowerCase())+'" data-kind="'+kind+'" data-catalog="'+esc(catName)+'" data-schema="'+esc(s.name)+'" data-table="'+esc(t.name)+'">'+
846+
return '<details class="tbl'+kcls+'" data-name="'+esc(t.name.toLowerCase())+'" data-kind="'+kind+'" data-catalog="'+esc(catName)+'" data-schema="'+esc(JSON.stringify(s.path))+'" data-table="'+esc(t.name)+'">'+
847847
'<summary>'+icon('caret','tri')+icon(iconName,'row-icon')+
848848
'<span class="t-name">'+esc(t.name)+'</span><span class="t-meta">'+t.cols+' cols</span></summary>'+
849849
'<div class="t-body">'+
@@ -948,7 +948,7 @@ <h1 id="worker-name">—</h1>
948948
if (!d.classList || !d.classList.contains('tbl') || !d.open || d.dataset.loaded) return;
949949
d.dataset.loaded = '1';
950950
const body = d.querySelector('.cols');
951-
fetchColumns(d.dataset.catalog, d.dataset.schema, d.dataset.table).then(info=>{
951+
fetchColumns(d.dataset.catalog, JSON.parse(d.dataset.schema), d.dataset.table).then(info=>{
952952
body.innerHTML = info.columns.map(col=>
953953
'<div class="col-item"><div class="col"><span class="col-name">'+esc(col.name)+'</span><span class="col-type">'+esc(duckdbType(col.type))+'</span></div>'+
954954
(col.comment? '<div class="col-comment">'+esc(col.comment)+'</div>':'')+'</div>').join('');

‎vgi/http/vgi-client.js‎

Lines changed: 262 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)