|
| 1 | +# Copyright 2025, 2026 Query Farm LLC - https://query.farm |
| 2 | + |
| 3 | +"""The SDK's own errors carry vgi-rpc error codes (WIRE_PROTOCOL.md §8). |
| 4 | +
|
| 5 | +Bad input is ``INVALID_ARGUMENT``, a missing object is ``NOT_FOUND``, a write |
| 6 | +against a read-only catalog is ``FAILED_PRECONDITION``, an unsupported operation |
| 7 | +is ``UNIMPLEMENTED``. A worker's own bug -- its output violating its declared |
| 8 | +schema -- stays unclassified (``UNKNOWN``) so it is never mistaken for bad input. |
| 9 | +""" |
| 10 | + |
| 11 | +from __future__ import annotations |
| 12 | + |
| 13 | +from collections.abc import Iterator |
| 14 | + |
| 15 | +import pyarrow as pa |
| 16 | +import pytest |
| 17 | +from vgi_rpc.errors import Code, error_code_of |
| 18 | +from vgi_rpc.rpc import RpcError |
| 19 | + |
| 20 | +from tests.conftest import FIXTURE_WORKER |
| 21 | +from vgi.arguments import Arg, Arguments, ArgumentValidationError |
| 22 | +from vgi.catalog.attach_option import MissingAttachOptionsError |
| 23 | +from vgi.client.client import Client, ClientError |
| 24 | +from vgi.exceptions import ( |
| 25 | + ArgumentTypeError, |
| 26 | + CatalogReadOnlyError, |
| 27 | + InvalidArgumentError, |
| 28 | + NotFoundError, |
| 29 | + SchemaValidationError, |
| 30 | + UnsupportedOperationError, |
| 31 | +) |
| 32 | +from vgi.scalar_function import TypeMismatchError |
| 33 | + |
| 34 | + |
| 35 | +def _is_numeric(t: pa.DataType) -> bool: |
| 36 | + return pa.types.is_integer(t) or pa.types.is_floating(t) |
| 37 | + |
| 38 | + |
| 39 | +class TestDeclaredCodes: |
| 40 | + """Each SDK error class declares the code the cross-SDK mapping assigns it.""" |
| 41 | + |
| 42 | + @pytest.mark.parametrize( |
| 43 | + ("exc", "code", "base"), |
| 44 | + [ |
| 45 | + (ArgumentValidationError("bad", constraint="must be >= 1"), Code.INVALID_ARGUMENT, ValueError), |
| 46 | + (ArgumentTypeError("bad type"), Code.INVALID_ARGUMENT, SchemaValidationError), |
| 47 | + (InvalidArgumentError("no overload"), Code.INVALID_ARGUMENT, ValueError), |
| 48 | + (MissingAttachOptionsError("cat", ["token"]), Code.INVALID_ARGUMENT, ValueError), |
| 49 | + (NotFoundError("Unknown function: 'x'"), Code.NOT_FOUND, ValueError), |
| 50 | + (CatalogReadOnlyError("read-only"), Code.FAILED_PRECONDITION, Exception), |
| 51 | + (UnsupportedOperationError("Table create not implemented."), Code.UNIMPLEMENTED, NotImplementedError), |
| 52 | + ], |
| 53 | + ) |
| 54 | + def test_code_and_hierarchy(self, exc: BaseException, code: Code, base: type[BaseException]) -> None: |
| 55 | + """The code is declared and the class still satisfies existing handlers.""" |
| 56 | + assert error_code_of(exc) == code |
| 57 | + assert isinstance(exc, base) |
| 58 | + |
| 59 | + def test_worker_output_schema_violation_stays_unknown(self) -> None: |
| 60 | + """A worker's output not matching its schema is a bug, not bad input.""" |
| 61 | + assert error_code_of(SchemaValidationError("output mismatch")) == Code.UNKNOWN |
| 62 | + |
| 63 | + def test_type_mismatch_on_input_is_invalid_argument(self) -> None: |
| 64 | + """An input parameter of the wrong type is the caller's error.""" |
| 65 | + exc = TypeMismatchError("x", param_name="x", expected_type=pa.int64(), actual_type=pa.string()) |
| 66 | + assert error_code_of(exc) == Code.INVALID_ARGUMENT |
| 67 | + assert isinstance(exc, TypeError) |
| 68 | + |
| 69 | + def test_type_mismatch_on_return_stays_unknown(self) -> None: |
| 70 | + """A wrong return type is the worker's bug.""" |
| 71 | + exc = TypeMismatchError("out", param_name="return", expected_type=pa.int64(), actual_type=pa.string()) |
| 72 | + assert error_code_of(exc) == Code.UNKNOWN |
| 73 | + |
| 74 | + def test_type_bound_failure_is_invalid_argument(self) -> None: |
| 75 | + """A failed ``type_bound`` raises the coded subclass of SchemaValidationError.""" |
| 76 | + arg: Arg[object] = Arg(0, type_bound=_is_numeric) |
| 77 | + with pytest.raises(SchemaValidationError) as excinfo: |
| 78 | + arg.validate_type_bound(pa.string()) |
| 79 | + assert isinstance(excinfo.value, ArgumentTypeError) |
| 80 | + assert error_code_of(excinfo.value) == Code.INVALID_ARGUMENT |
| 81 | + |
| 82 | + |
| 83 | +def _rpc_error(exc: BaseException) -> RpcError: |
| 84 | + cause = exc.__cause__ |
| 85 | + assert isinstance(cause, RpcError), f"expected an RpcError cause, got {cause!r}" |
| 86 | + return cause |
| 87 | + |
| 88 | + |
| 89 | +@pytest.fixture(scope="module") |
| 90 | +def client() -> Iterator[Client]: |
| 91 | + """A client on the fixture worker.""" |
| 92 | + with Client(FIXTURE_WORKER) as c: |
| 93 | + yield c |
| 94 | + |
| 95 | + |
| 96 | +class TestOverTheWire: |
| 97 | + """The fixture worker's rejections arrive with their code.""" |
| 98 | + |
| 99 | + def test_scalar_type_rejection(self, client: Client) -> None: |
| 100 | + """``double('abc')``: a string fails the numeric type bound.""" |
| 101 | + batch = pa.record_batch({"x": pa.array(["abc"])}) |
| 102 | + with pytest.raises(ClientError) as excinfo: |
| 103 | + list(client.scalar_function(function_name="double", schema_path=["main"], input=iter([batch]))) |
| 104 | + assert _rpc_error(excinfo.value).error_code == "INVALID_ARGUMENT" |
| 105 | + |
| 106 | + def test_table_argument_constraint(self, client: Client) -> None: |
| 107 | + """``sequence(10, batch_size := 0)``: violates ``batch_size >= 1``.""" |
| 108 | + with pytest.raises(ClientError) as excinfo: |
| 109 | + list( |
| 110 | + client.table_function( |
| 111 | + function_name="sequence", |
| 112 | + schema_path=["main"], |
| 113 | + arguments=Arguments(positional=(pa.scalar(10),), named={"batch_size": pa.scalar(0)}), |
| 114 | + ) |
| 115 | + ) |
| 116 | + err = _rpc_error(excinfo.value) |
| 117 | + assert "must be >= 1" in str(err) |
| 118 | + assert err.error_code == "INVALID_ARGUMENT" |
| 119 | + |
| 120 | + def test_unknown_function(self, client: Client) -> None: |
| 121 | + """An unknown function name is NOT_FOUND.""" |
| 122 | + with pytest.raises(ClientError) as excinfo: |
| 123 | + list( |
| 124 | + client.table_function( |
| 125 | + function_name="nonexistent_function", |
| 126 | + schema_path=["main"], |
| 127 | + arguments=Arguments(positional=()), |
| 128 | + ) |
| 129 | + ) |
| 130 | + assert _rpc_error(excinfo.value).error_code == "NOT_FOUND" |
0 commit comments