Skip to content

Commit bdbb51f

Browse files
authored
Merge Python SDK 0.48.0 for VGI protocol v3 into main
Release Python SDK 0.48.0 for VGI protocol v3
2 parents fd4f5b9 + 827f49b commit bdbb51f

239 files changed

Lines changed: 50686 additions & 1921 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/ci.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,10 @@ jobs:
4141
bun-version: "1.3.14"
4242

4343
- name: Install dependencies
44-
run: uv sync --all-extras
44+
run: uv sync --all-extras --python ${{ matrix.python-version }}
4545

4646
- name: Run tests
47-
run: uv run pytest
47+
run: uv run --python ${{ matrix.python-version }} pytest
4848

4949
lint:
5050
name: Lint

‎.github/workflows/integration.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ concurrency:
2727
env:
2828
# The test suite and native Actions artifact must come from this exact ref.
2929
# The downloader verifies its provenance against the checked-out commit.
30-
VGI_REF: fc3e08f7e7fd800165a0edc00313de46fed15329
30+
VGI_REF: 7b1e1fbbe745d77fdacf52d6d135fa3ef4491d48
31+
VGI_NATIVE_RUNNER_BRANCH: release/vgi-v3-ci-20261028
3132
# CI is not a user: its ATTACHes should not land in Query.Farm's adoption data.
3233
# The vgi extension fires a telemetry event per successful ATTACH, and this
3334
# suite performs several hundred of them per lane, so opting out keeps the

‎conformance/catalog-contracts.json‎

Lines changed: 187 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Scalar projection worker proof
2+
3+
Run `python conformance/projection-expressions/run_worker.py --worker COMMAND`
4+
from the Python checkout. An HTTP URL is accepted as well. The runner uses the
5+
typed public bind, proposal and init RPCs against the common example fixture;
6+
it does not simulate optimizer replacement.
7+
8+
The fixture is `example.main.projection_rows`, backed by
9+
`projection_sequence()` with no arguments. It emits nullable `n: int32` with
10+
values 0 through 9 and declares `STABLE_WITHIN_QUERY`. It offers
11+
`vgi.expression.duckdb.scalar.v1` and accepts
12+
exactly `CAST("n" AS BIGINT)` under the default evaluation context. Requested
13+
result fields, including their metadata, append unchanged. Other expressions
14+
or contexts decline. The Python reference is
15+
`env VGI_PROJECTION_COMMON=1 python -m tests.projection_reference`.
16+
17+
The runner verifies ordinary scan, partial acceptance, complete decline,
18+
unsupported-context decline, complete acceptance, reuse of accepted bind
19+
state, and an independent ordinary rebind. It requires exact result schemas
20+
and rows throughout, including preservation of repeatability. Thirteen malformed wire requests also verify rejection of
21+
bad expression IPC, counts, names, metadata, owners, offers, contexts and outer
22+
row/schema shape and null/unknown repeatability. The runner has 19 cases in total.
23+
Native optimizer replacement and malformed-provider
24+
responses have separate tests; this runner does not claim those properties.
25+
26+
Add `--wrapped-framing` to qualify raw outer IPC handling: valid physical EOF
27+
and legacy EOS, extra batches, trailing bytes, repeated EOS, truncated EOS/body
28+
and requests exceeding 8 MiB. This adds eight cases, for 27 total. The paired
29+
Go RPC dependency passes this mode over subprocess and HTTP; its unit tests
30+
also check rejection before Arrow decoding. Python passes the 19 semantic
31+
cases but its generic RPC wrapper accepts an extra outer batch before typed
32+
validation; complete Python outer framing/raw-size rejection remains an open
33+
release requirement. See the manifest checkpoint for each SDK's evidence.
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
# Copyright 2026 Query Farm LLC - https://query.farm
2+
3+
"""Exercise optional projection negotiation and execution through public RPCs.
4+
5+
The portable fixture is example/main/projection_rows, backed by
6+
projection_sequence(). It emits nullable int32 n=0..9 and accepts only the
7+
canonical CAST("n" AS BIGINT) expression in the default evaluation context.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import argparse
13+
import json
14+
from contextlib import closing
15+
from dataclasses import replace
16+
17+
import pyarrow as pa
18+
from vgi_rpc.rpc import RpcError
19+
20+
from vgi.arguments import Arguments
21+
from vgi.catalog.capabilities import ROW_SCALAR_EXPRESSION
22+
from vgi.catalog.evaluation_context import QueryEvaluationContext
23+
from vgi.catalog.sql import SqlExpression
24+
from vgi.client import Client
25+
from vgi.invocation import BindRepeatability, BindResponse, FunctionType
26+
from vgi.protocol import (
27+
BindRequest,
28+
ClientCapabilities,
29+
InitRequest,
30+
TableFunctionProjectionRequest,
31+
)
32+
33+
34+
def proposal(bind_call, bind_result, sql, *, context=None):
35+
"""Construct typed, canonical candidate fields in stable proposal order."""
36+
return TableFunctionProjectionRequest(
37+
bind_call=bind_call,
38+
bind_result=bind_result,
39+
expressions=[
40+
SqlExpression(
41+
sql=text,
42+
profile=ROW_SCALAR_EXPRESSION,
43+
schema_path=["main"],
44+
evaluation_context=context or QueryEvaluationContext(),
45+
).serialize_to_bytes()
46+
for text in sql
47+
],
48+
result_schema=pa.schema(
49+
[
50+
pa.field(f"wide_{index}", pa.int64(), metadata={b"projection-fixture": str(index).encode()})
51+
for index in range(len(sql))
52+
]
53+
),
54+
)
55+
56+
57+
def scan(proxy, bind_call, bind_result):
58+
"""Consume the existing producer stream with the selected bind contract."""
59+
with closing(
60+
proxy.init(
61+
request=InitRequest(
62+
bind_call=bind_call,
63+
output_schema=bind_result.output_schema,
64+
bind_opaque_data=bind_result.opaque_data,
65+
)
66+
)
67+
) as stream:
68+
batches = [output.batch for output in stream]
69+
for batch in batches:
70+
assert batch.schema.equals(bind_result.output_schema, check_metadata=True), batch.schema
71+
actual = pa.Table.from_batches(batches, schema=bind_result.output_schema)
72+
assert actual.to_pylist() == [
73+
{field.name: number for field in bind_result.output_schema} for number in range(10)
74+
], actual.to_pylist()
75+
76+
77+
class RawProjectionRequest(TableFunctionProjectionRequest):
78+
"""Send deliberately malformed wire bytes without local model validation."""
79+
80+
def serialize_to_bytes(self):
81+
"""Return the intentional invalid test payload unchanged."""
82+
return self.raw_payload
83+
84+
85+
def raw_request(request, field, value):
86+
"""Replace one packed field while retaining the exact outer wire schema."""
87+
with pa.ipc.open_stream(request.serialize_to_bytes()) as reader:
88+
batch = reader.read_next_batch()
89+
index = batch.schema.get_field_index(field)
90+
columns = list(batch.columns)
91+
columns[index] = pa.array([value], type=batch.schema.field(index).type)
92+
return raw_batch(pa.RecordBatch.from_arrays(columns, schema=batch.schema))
93+
94+
95+
def raw_batch(batch):
96+
"""Pack an intentionally invalid row count or schema for wire tests."""
97+
sink = pa.BufferOutputStream()
98+
with pa.ipc.new_stream(sink, batch.schema) as writer:
99+
writer.write_batch(batch)
100+
return raw_payload(sink.getvalue().to_pybytes())
101+
102+
103+
def raw_payload(payload):
104+
"""Wrap exact bytes without normalizing their IPC framing."""
105+
invalid = object.__new__(RawProjectionRequest)
106+
object.__setattr__(invalid, "raw_payload", payload)
107+
return invalid
108+
109+
110+
def wrapped_framing_cases(request):
111+
"""Return valid EOF alternatives and malformed single-record envelopes."""
112+
encoded = request.serialize_to_bytes()
113+
assert encoded.endswith(b"\xff\xff\xff\xff\x00\x00\x00\x00")
114+
with pa.ipc.open_stream(encoded) as reader:
115+
batch = reader.read_next_batch()
116+
sink = pa.BufferOutputStream()
117+
with pa.ipc.new_stream(sink, batch.schema) as writer:
118+
writer.write_batch(batch)
119+
writer.write_batch(batch)
120+
return [
121+
("physical EOF", encoded[:-8], True),
122+
("legacy EOS", encoded[:-8] + b"\x00\x00\x00\x00", True),
123+
("extra IPC batch", sink.getvalue().to_pybytes(), False),
124+
("trailing IPC bytes", encoded + b"garbage", False),
125+
("repeated EOS", encoded + encoded[-8:], False),
126+
("truncated EOS", encoded[:-1], False),
127+
("truncated record body", encoded[:-9], False),
128+
("oversized wrapped request", encoded + bytes(8 * 1024 * 1024), False),
129+
]
130+
131+
132+
def invalid_cases(request):
133+
"""Exercise handler boundaries independently of Python's local validators."""
134+
expressions = request.expression_records()
135+
return [
136+
("empty expressions", "expressions", []),
137+
(
138+
"unknown repeatability",
139+
"bind_result",
140+
raw_request(request.bind_result, "repeatability", "ALWAYS").serialize_to_bytes(),
141+
),
142+
(
143+
"null repeatability",
144+
"bind_result",
145+
raw_request(request.bind_result, "repeatability", None).serialize_to_bytes(),
146+
),
147+
("malformed expression IPC", "expressions", [b"invalid", request.expressions[1]]),
148+
("wrong result count", "result_schema", pa.schema([]).serialize().to_pybytes()),
149+
(
150+
"source/result name collision",
151+
"result_schema",
152+
pa.schema([pa.field("N", pa.int64()), pa.field("wide_1", pa.int64())]).serialize().to_pybytes(),
153+
),
154+
(
155+
"unexpected result schema metadata",
156+
"result_schema",
157+
request.result_schema.with_metadata({b"not": b"a new source"}).serialize().to_pybytes(),
158+
),
159+
("absent owner", "bind_call", replace(request.bind_call, schema_path=None).serialize_to_bytes()),
160+
(
161+
"unoffered profile",
162+
"bind_result",
163+
replace(request.bind_result, projection_expression_profiles=[]).serialize_to_bytes(),
164+
),
165+
(
166+
"different expression contexts",
167+
"expressions",
168+
[
169+
request.expressions[0],
170+
replace(
171+
expressions[1], evaluation_context=replace(QueryEvaluationContext(), timezone="Europe/Paris")
172+
).serialize_to_bytes(),
173+
],
174+
),
175+
]
176+
177+
178+
def run(worker, *, wrapped_framing=False):
179+
"""Return named successes, failing immediately on any protocol mismatch."""
180+
client = Client.from_http(worker) if worker.startswith(("http://", "https://")) else Client(worker, pool=None)
181+
results = []
182+
with client:
183+
attached = client.catalog_attach(
184+
name="example",
185+
data_version_spec=None,
186+
implementation_version=None,
187+
client_capabilities=ClientCapabilities(
188+
engine="projection-conformance", native_formats=[], catalogs=[], can_stream=True, filter_encodings=[]
189+
),
190+
)
191+
client.table_get(attach_opaque_data=attached.attach_opaque_data, schema_path=["main"], name="projection_rows")
192+
bind_call = BindRequest(
193+
function_name="projection_sequence",
194+
schema_path=["main"],
195+
function_type=FunctionType.TABLE,
196+
arguments=Arguments(),
197+
attach_opaque_data=attached.attach_opaque_data,
198+
)
199+
# The low-level typed proxy intentionally avoids Client.table_function(),
200+
# which would perform a fresh ordinary bind and hide accepted state.
201+
assert client._primary is not None
202+
proxy = client._primary.proxy
203+
original = proxy.bind(request=bind_call)
204+
assert original.projection_expression_profiles == [ROW_SCALAR_EXPRESSION]
205+
assert original.repeatability is BindRepeatability.STABLE_WITHIN_QUERY
206+
assert original.output_schema.equals(pa.schema([pa.field("n", pa.int32())]), check_metadata=True)
207+
scan(proxy, bind_call, original)
208+
results.append("ordinary scan before negotiation")
209+
210+
cases = [
211+
("partial acceptance", ['CAST("n" AS BIGINT)', '"n" + 1', 'CAST("n" AS BIGINT)'], [0, 2], None),
212+
("complete decline", ['"n" + 1'], [], None),
213+
(
214+
"unsupported context decline",
215+
['CAST("n" AS BIGINT)'],
216+
[],
217+
replace(QueryEvaluationContext(), timezone="Europe/Paris"),
218+
),
219+
("complete acceptance", ['CAST("n" AS BIGINT)'], [0], None),
220+
]
221+
for name, expressions, expected_indices, context in cases:
222+
request = proposal(bind_call, original, expressions, context=context)
223+
response = proxy.table_function_projection(request=request)
224+
response.validate_request(request)
225+
assert response.accepted_indices == expected_indices, response
226+
selected: BindResponse = response.bind_result or original
227+
assert selected.repeatability is original.repeatability
228+
scan(proxy, bind_call, selected)
229+
# Reusing accepted bind bytes must retain the accepted schema/state.
230+
if expected_indices:
231+
scan(proxy, bind_call, selected)
232+
results.append(name)
233+
234+
valid = proposal(bind_call, original, ['CAST("n" AS BIGINT)', 'CAST("n" AS BIGINT)'])
235+
if wrapped_framing:
236+
for name, payload, accepted in wrapped_framing_cases(valid):
237+
try:
238+
response = proxy.table_function_projection(request=raw_payload(payload))
239+
except RpcError:
240+
if accepted:
241+
raise
242+
else:
243+
if not accepted:
244+
raise AssertionError("malformed wrapped request was not rejected: " + name)
245+
response.validate_request(valid)
246+
assert response.accepted_indices == [0, 1]
247+
results.append(("accept " if accepted else "reject ") + name)
248+
for name, field, value in invalid_cases(valid):
249+
try:
250+
proxy.table_function_projection(request=raw_request(valid, field, value))
251+
except RpcError:
252+
results.append("reject " + name)
253+
else:
254+
raise AssertionError("malformed proposal was not rejected: " + name)
255+
256+
with pa.ipc.open_stream(valid.serialize_to_bytes()) as reader:
257+
row = reader.read_next_batch()
258+
malformed = [
259+
("zero proposal rows", row.slice(0, 0)),
260+
(
261+
"multiple proposal rows",
262+
pa.RecordBatch.from_arrays([pa.concat_arrays([column, column]) for column in row], schema=row.schema),
263+
),
264+
("extra proposal field", row.append_column("unexpected", pa.array([1]))),
265+
]
266+
for name, batch in malformed:
267+
try:
268+
proxy.table_function_projection(request=raw_batch(batch))
269+
except RpcError:
270+
results.append("reject " + name)
271+
else:
272+
raise AssertionError("malformed proposal envelope was not rejected: " + name)
273+
274+
rebound = proxy.bind(request=bind_call)
275+
assert rebound.repeatability is original.repeatability
276+
assert rebound.output_schema.equals(original.output_schema, check_metadata=True)
277+
scan(proxy, bind_call, rebound)
278+
results.append("independent ordinary rebind")
279+
client.catalog_detach(attach_opaque_data=attached.attach_opaque_data)
280+
return results
281+
282+
283+
def main():
284+
"""Run the same transport-level proof against any example worker."""
285+
parser = argparse.ArgumentParser(description=__doc__)
286+
parser.add_argument("--worker", required=True, help="Worker command or HTTP URL")
287+
parser.add_argument("--wrapped-framing", action="store_true", help="Also qualify raw outer IPC framing and size")
288+
args = parser.parse_args()
289+
results = run(args.worker, wrapped_framing=args.wrapped_framing)
290+
print(json.dumps({"passed": len(results), "cases": results}))
291+
292+
293+
if __name__ == "__main__":
294+
main()

‎docs/api/arguments.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ schemas from these declarations and validates inputs before your function runs.
88

99
::: vgi.arguments
1010

11+
## Typed values
12+
13+
::: vgi.typed_values
14+
1115
## Argument specifications
1216

1317
::: vgi.argument_spec

0 commit comments

Comments
 (0)