|
| 1 | +# Copyright 2025, 2026 Query Farm LLC - https://query.farm |
| 2 | + |
| 3 | +"""``Client.list_protocols`` / ``Client.describe_protocol`` over reflection. |
| 4 | +
|
| 5 | +Every worker hosts ``vgi_rpc.Reflection.v1`` on every transport. These tests |
| 6 | +ask the fixture worker what it hosts through the Python ``Client`` on each |
| 7 | +transport it speaks, and check the answer against the worker's own |
| 8 | +``RpcServer`` bindings. The cross-language coverage of the same surface is the |
| 9 | +C++ repo's ``test/sql/integration/reflection/``. |
| 10 | +""" |
| 11 | + |
| 12 | +from __future__ import annotations |
| 13 | + |
| 14 | +import re |
| 15 | +from collections.abc import Callable |
| 16 | +from typing import Any |
| 17 | + |
| 18 | +import pytest |
| 19 | +from vgi_rpc import ReflectionNotSupportedError |
| 20 | +from vgi_rpc.rpc import RpcError |
| 21 | + |
| 22 | +from tests.conftest import SUBPROCESS_FIXTURE_WORKER |
| 23 | +from vgi._test_fixtures.worker import ExampleWorker |
| 24 | +from vgi.client import Client, ClientError, HostedProtocol |
| 25 | +from vgi.protocol import VgiProtocol |
| 26 | +from vgi.rpc_server import build_rpc_server |
| 27 | + |
| 28 | +_SECONDARY = "conformance.Secondary.v1" |
| 29 | +_REFLECTION = "vgi_rpc.Reflection.v1" |
| 30 | +_EXPECTED_NAMES = ["vgi.v2", _SECONDARY, _REFLECTION] |
| 31 | +_HASH = re.compile(r"^[0-9a-f]{64}$") |
| 32 | + |
| 33 | + |
| 34 | +def _server_view() -> list[HostedProtocol]: |
| 35 | + """Return what the fixture worker's own server binds, as the client would report it.""" |
| 36 | + server = build_rpc_server(ExampleWorker(quiet=True), transport="pipe") |
| 37 | + return [ |
| 38 | + HostedProtocol(name=b.name, version=b.version or "", hash=b.protocol_hash) for b in server.bindings.values() |
| 39 | + ] |
| 40 | + |
| 41 | + |
| 42 | +def _assert_fixture_listing(listing: list[HostedProtocol]) -> None: |
| 43 | + """The fixture's hosted set: ``vgi.v2`` first, the secondary, then reflection.""" |
| 44 | + assert all(isinstance(p, HostedProtocol) for p in listing) |
| 45 | + assert [p.name for p in listing] == _EXPECTED_NAMES |
| 46 | + assert listing[0].version == VgiProtocol.protocol_version |
| 47 | + for p in listing: |
| 48 | + assert _HASH.match(p.hash), f"{p.name} hash {p.hash!r} is not 64 lowercase hex chars" |
| 49 | + assert listing == _server_view() |
| 50 | + |
| 51 | + |
| 52 | +class TestListProtocols: |
| 53 | + """Each transport the fixture worker speaks reports the same hosted set.""" |
| 54 | + |
| 55 | + def test_client_transport(self, client_transport: Callable[[], Client]) -> None: |
| 56 | + """Launch / pooled subprocess / HTTP, per the shared transport matrix.""" |
| 57 | + with client_transport() as client: |
| 58 | + _assert_fixture_listing(client.list_protocols()) |
| 59 | + |
| 60 | + def test_stdio_direct(self) -> None: |
| 61 | + """A directly spawned stdin/stdout worker, independent of the default matrix.""" |
| 62 | + with Client(SUBPROCESS_FIXTURE_WORKER, pool=None) as client: |
| 63 | + first = client.list_protocols() |
| 64 | + _assert_fixture_listing(first) |
| 65 | + # The pipe is still usable for vgi.v2 after a reflection call. |
| 66 | + assert client.list_protocols() == first |
| 67 | + |
| 68 | + def test_http(self, _shared_http_base_url: Callable[[], str]) -> None: |
| 69 | + """``Client.from_http`` against the fixture HTTP server.""" |
| 70 | + pytest.importorskip("vgi_rpc.http") |
| 71 | + with Client.from_http(_shared_http_base_url()) as client: |
| 72 | + _assert_fixture_listing(client.list_protocols()) |
| 73 | + |
| 74 | + def test_requires_started_client(self) -> None: |
| 75 | + """An unstarted client raises ``ClientError`` rather than spawning a worker.""" |
| 76 | + with pytest.raises(ClientError, match="not started"): |
| 77 | + Client(SUBPROCESS_FIXTURE_WORKER, pool=None).list_protocols() |
| 78 | + |
| 79 | + |
| 80 | +class TestDescribeProtocol: |
| 81 | + """``describe_protocol`` returns one protocol's methods.""" |
| 82 | + |
| 83 | + def test_describes_secondary(self, client_transport: Callable[[], Client]) -> None: |
| 84 | + """The secondary's description matches its listed hash.""" |
| 85 | + with client_transport() as client: |
| 86 | + listed = {p.name: p for p in client.list_protocols()} |
| 87 | + described = client.describe_protocol(_SECONDARY) |
| 88 | + assert described.protocol_name == _SECONDARY |
| 89 | + assert described.protocol_hash == listed[_SECONDARY].hash |
| 90 | + assert "echo_string" in described.methods |
| 91 | + |
| 92 | + def test_describes_vgi_v2(self) -> None: |
| 93 | + """``vgi.v2`` describes with its version and the client's own method set.""" |
| 94 | + with Client(SUBPROCESS_FIXTURE_WORKER, pool=None) as client: |
| 95 | + described = client.describe_protocol("vgi.v2") |
| 96 | + assert described.protocol_version == VgiProtocol.protocol_version |
| 97 | + assert {"bind", "init"} <= set(described.methods) |
| 98 | + |
| 99 | + def test_unhosted_protocol_raises(self) -> None: |
| 100 | + """A protocol the worker does not host is a ``ClientError``.""" |
| 101 | + with ( |
| 102 | + Client(SUBPROCESS_FIXTURE_WORKER, pool=None) as client, |
| 103 | + pytest.raises(ClientError, match="nope.v1"), |
| 104 | + ): |
| 105 | + client.describe_protocol("nope.v1") |
| 106 | + |
| 107 | + |
| 108 | +def _patch_listing(monkeypatch: pytest.MonkeyPatch, error: RpcError) -> None: |
| 109 | + """Make vgi-rpc's ``list_protocols``, as the client calls it, raise *error*.""" |
| 110 | + |
| 111 | + def _fail(target: object) -> Any: |
| 112 | + raise error |
| 113 | + |
| 114 | + monkeypatch.setattr("vgi.client.client.rpc_list_protocols", _fail) |
| 115 | + |
| 116 | + |
| 117 | +class TestPreReflectionWorker: |
| 118 | + """A worker without reflection is reported as hosting only ``vgi.v2``. |
| 119 | +
|
| 120 | + Which server answers mean "no reflection" is vgi-rpc's call (it raises |
| 121 | + ``ReflectionNotSupportedError``, tested there); the client's job is only |
| 122 | + to map that one exception to the fallback and everything else to |
| 123 | + ``ClientError``. |
| 124 | + """ |
| 125 | + |
| 126 | + def test_falls_back_to_vgi_v2(self, monkeypatch: pytest.MonkeyPatch) -> None: |
| 127 | + """``ReflectionNotSupportedError`` yields one ``vgi.v2`` entry with an empty hash.""" |
| 128 | + error = ReflectionNotSupportedError( |
| 129 | + "ProtocolNotSupportedError", |
| 130 | + "not hosted", |
| 131 | + "", |
| 132 | + error_code="UNIMPLEMENTED", |
| 133 | + error_kind="protocol_not_supported", |
| 134 | + ) |
| 135 | + _patch_listing(monkeypatch, error) |
| 136 | + with Client(SUBPROCESS_FIXTURE_WORKER, pool=None) as client: |
| 137 | + assert client.list_protocols() == [HostedProtocol(name="vgi.v2", version="", hash="")] |
| 138 | + |
| 139 | + def test_other_errors_raise(self, monkeypatch: pytest.MonkeyPatch) -> None: |
| 140 | + """Any other reflection failure is a ``ClientError``, not a silent fallback.""" |
| 141 | + _patch_listing(monkeypatch, RpcError("RuntimeError", "boom", "", error_code="INTERNAL")) |
| 142 | + with Client(SUBPROCESS_FIXTURE_WORKER, pool=None) as client, pytest.raises(ClientError, match="boom"): |
| 143 | + client.list_protocols() |
0 commit comments