|
|
|
@@ -0,0 +1,728 @@
|
|
|
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
|
|
|
|
|
|
|
|
|
"""Contract tests for Python exact first-class Function revocation."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import contextlib
|
|
|
|
|
import http.server
|
|
|
|
|
import json
|
|
|
|
|
import threading
|
|
|
|
|
from collections.abc import AsyncIterator, Iterator
|
|
|
|
|
from typing import Any, Callable
|
|
|
|
|
|
|
|
|
|
import pyarrow as pa
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
import lancedb
|
|
|
|
|
from lancedb import _lancedb as _native
|
|
|
|
|
from lancedb.remote.errors import HttpError
|
|
|
|
|
|
|
|
|
|
_REVOKE_PATH = "/v1/functions/revoke"
|
|
|
|
|
_LOOKUP_PATH = "/v1/functions/lookup"
|
|
|
|
|
_REVOKE_CATALOG_NAME = "text.normalize.revoke-name"
|
|
|
|
|
_REVOKE_FUNCTION_ID = "fn.exact.revoke-handle"
|
|
|
|
|
_REVOKE_SERVER_MESSAGE_MARKER = (
|
|
|
|
|
"SERVER_REVOKE_DIAGNOSTIC_MARKER id=fn.exact.revoke-handle "
|
|
|
|
|
"name=text.normalize.revoke-name"
|
|
|
|
|
)
|
|
|
|
|
_SENSITIVE_BODY_MARKER = "SENSITIVE_REVOKE_BODY_MARKER"
|
|
|
|
|
_CONFLICTING_MESSAGE_CODE = "revoked_function"
|
|
|
|
|
|
|
|
|
|
# Pinned Rust-canonical schema-only type IPC (base64). Same fixtures as lookup /
|
|
|
|
|
# remove tests: PyArrow FileWriter bytes are not byte-identical to Arrow Rust.
|
|
|
|
|
_INT32_TYPE_IPC_B64 = (
|
|
|
|
|
"QVJST1cxAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP"
|
|
|
|
|
"////94AAAAEAAAAAAACgAMAAoACQAEAAoAAAAQAAAAAAEEAAgACAAAAAQACAAAAAQAAAABAAAAFAAAABAAFAAQ"
|
|
|
|
|
"AA4ADwAEAAAACAAQAAAAGAAAACAAAAAAAAECHAAAAAgADAAEAAsACAAAACAAAAAAAAABAAAAAAAAAAAAAAAA/"
|
|
|
|
|
"////wAAAAAUAAAAAAAAAAwAFAASAAwACAAEAAwAAABsAAAAcAAAABAAAAAAAAQACAAIAAAABAAIAAAABAAAAA"
|
|
|
|
|
"EAAAAUAAAAEAAUABAADgAPAAQAAAAIABAAAAAYAAAAIAAAAAAAAQIcAAAACAAMAAQACwAIAAAAIAAAAAAAAAE"
|
|
|
|
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACQAAAAQVJST1cx"
|
|
|
|
|
)
|
|
|
|
|
_UTF8_TYPE_IPC_B64 = (
|
|
|
|
|
"QVJST1cxAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP"
|
|
|
|
|
"////94AAAAEAAAAAAACgAMAAoACQAEAAoAAAAQAAAAAAEEAAgACAAAAAQACAAAAAQAAAABAAAAFAAAABAAFAAQ"
|
|
|
|
|
"AA4ADwAEAAAACAAQAAAAGAAAAAwAAAAAAAEFEAAAAAAAAAAEAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/"
|
|
|
|
|
"////wAAAAAQAAAADAAUABIADAAIAAQADAAAAGAAAABkAAAAEAAAAAAABAAIAAgAAAAEAAgAAAAEAAAAAQAAAB"
|
|
|
|
|
"QAAAAQABQAEAAOAA8ABAAAAAgAEAAAABgAAAAMAAAAAAABBRAAAAAAAAAABAAEAAQAAAAAAAAAAAAAAAAAAAA"
|
|
|
|
|
"AAAAAAAAAAIAAAABBUlJPVzE="
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
_DELETED_REVOKE_KEYWORDS = (
|
|
|
|
|
"function_id",
|
|
|
|
|
"name",
|
|
|
|
|
"idempotency_key",
|
|
|
|
|
"retry_key",
|
|
|
|
|
"user_version",
|
|
|
|
|
"version",
|
|
|
|
|
"reason",
|
|
|
|
|
"expiry",
|
|
|
|
|
"force",
|
|
|
|
|
"if_exists",
|
|
|
|
|
"remove",
|
|
|
|
|
"delete",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _function_error_cls(*, required: bool = True):
|
|
|
|
|
"""Resolve FunctionError from the live module (records RED when absent)."""
|
|
|
|
|
from lancedb import exceptions as exc_mod
|
|
|
|
|
|
|
|
|
|
cls = getattr(exc_mod, "FunctionError", None)
|
|
|
|
|
if cls is None:
|
|
|
|
|
if required:
|
|
|
|
|
pytest.fail("lancedb.exceptions.FunctionError is missing")
|
|
|
|
|
return type("MissingFunctionError", (), {})
|
|
|
|
|
return cls
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sample_function_wire() -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"format_version": 1,
|
|
|
|
|
"id": _REVOKE_FUNCTION_ID,
|
|
|
|
|
"signature": {
|
|
|
|
|
"parameters": [
|
|
|
|
|
{"name": "text", "data_type_ipc": _UTF8_TYPE_IPC_B64},
|
|
|
|
|
{"name": "limit", "data_type_ipc": _INT32_TYPE_IPC_B64},
|
|
|
|
|
],
|
|
|
|
|
"output": {
|
|
|
|
|
"data_type_ipc": _UTF8_TYPE_IPC_B64,
|
|
|
|
|
"nullable": True,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _lookup_success_body() -> bytes:
|
|
|
|
|
return json.dumps({"function": _sample_function_wire()}).encode("utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _read_body(request: http.server.BaseHTTPRequestHandler) -> bytes:
|
|
|
|
|
content_len = int(request.headers.get("Content-Length", 0))
|
|
|
|
|
if content_len <= 0:
|
|
|
|
|
return b""
|
|
|
|
|
return request.rfile.read(content_len)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_handler(handler: Callable[[http.server.BaseHTTPRequestHandler], None]):
|
|
|
|
|
class _Handler(http.server.BaseHTTPRequestHandler):
|
|
|
|
|
def do_GET(self):
|
|
|
|
|
handler(self)
|
|
|
|
|
|
|
|
|
|
def do_POST(self):
|
|
|
|
|
handler(self)
|
|
|
|
|
|
|
|
|
|
def log_message(self, format, *args): # noqa: A003
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
return _Handler
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _close_db(db: Any) -> None:
|
|
|
|
|
with contextlib.suppress(Exception):
|
|
|
|
|
inner = getattr(db, "_conn", None)
|
|
|
|
|
if inner is not None:
|
|
|
|
|
inner.close()
|
|
|
|
|
return
|
|
|
|
|
close = getattr(db, "close", None)
|
|
|
|
|
if callable(close):
|
|
|
|
|
close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@contextlib.contextmanager
|
|
|
|
|
def _mock_remote_db(handler) -> Iterator[Any]:
|
|
|
|
|
server = http.server.HTTPServer(("localhost", 0), _make_handler(handler))
|
|
|
|
|
port = server.server_address[1]
|
|
|
|
|
thread = threading.Thread(target=server.serve_forever)
|
|
|
|
|
thread.start()
|
|
|
|
|
db = None
|
|
|
|
|
try:
|
|
|
|
|
db = lancedb.connect(
|
|
|
|
|
"db://dev",
|
|
|
|
|
api_key="fake",
|
|
|
|
|
host_override=f"http://localhost:{port}",
|
|
|
|
|
client_config={
|
|
|
|
|
"retry_config": {
|
|
|
|
|
"retries": 2,
|
|
|
|
|
"backoff_factor": 0.0,
|
|
|
|
|
"backoff_jitter": 0.0,
|
|
|
|
|
},
|
|
|
|
|
"timeout_config": {"connect_timeout": 1},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
yield db
|
|
|
|
|
finally:
|
|
|
|
|
if db is not None:
|
|
|
|
|
_close_db(db)
|
|
|
|
|
server.shutdown()
|
|
|
|
|
thread.join()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@contextlib.asynccontextmanager
|
|
|
|
|
async def _mock_remote_db_async(handler) -> AsyncIterator[Any]:
|
|
|
|
|
server = http.server.HTTPServer(("localhost", 0), _make_handler(handler))
|
|
|
|
|
port = server.server_address[1]
|
|
|
|
|
thread = threading.Thread(target=server.serve_forever)
|
|
|
|
|
thread.start()
|
|
|
|
|
db = None
|
|
|
|
|
try:
|
|
|
|
|
db = await lancedb.connect_async(
|
|
|
|
|
"db://dev",
|
|
|
|
|
api_key="fake",
|
|
|
|
|
host_override=f"http://localhost:{port}",
|
|
|
|
|
client_config={
|
|
|
|
|
"retry_config": {
|
|
|
|
|
"retries": 2,
|
|
|
|
|
"backoff_factor": 0.0,
|
|
|
|
|
"backoff_jitter": 0.0,
|
|
|
|
|
},
|
|
|
|
|
"timeout_config": {"connect_timeout": 1},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
yield db
|
|
|
|
|
finally:
|
|
|
|
|
if db is not None:
|
|
|
|
|
_close_db(db)
|
|
|
|
|
server.shutdown()
|
|
|
|
|
thread.join()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _exception_chain_text(exc: BaseException) -> str:
|
|
|
|
|
parts: list[str] = []
|
|
|
|
|
seen: set[int] = set()
|
|
|
|
|
current: BaseException | None = exc
|
|
|
|
|
while current is not None and id(current) not in seen:
|
|
|
|
|
seen.add(id(current))
|
|
|
|
|
parts.append(str(current))
|
|
|
|
|
parts.append(repr(current))
|
|
|
|
|
current = current.__cause__
|
|
|
|
|
return "\n".join(parts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _assert_payload_free(exc: BaseException) -> None:
|
|
|
|
|
text = _exception_chain_text(exc)
|
|
|
|
|
assert _REVOKE_SERVER_MESSAGE_MARKER not in text
|
|
|
|
|
assert _REVOKE_CATALOG_NAME not in text
|
|
|
|
|
assert _REVOKE_FUNCTION_ID not in text
|
|
|
|
|
assert _SENSITIVE_BODY_MARKER not in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _assert_exact_revoke_function(function: object) -> None:
|
|
|
|
|
assert type(function) is lancedb.Function
|
|
|
|
|
assert function.id == _REVOKE_FUNCTION_ID
|
|
|
|
|
assert not hasattr(function, "name")
|
|
|
|
|
assert function.parameters == (
|
|
|
|
|
("text", pa.string()),
|
|
|
|
|
("limit", pa.int32()),
|
|
|
|
|
)
|
|
|
|
|
assert function.output_type == pa.string()
|
|
|
|
|
assert function.output_nullable is True
|
|
|
|
|
assert _REVOKE_CATALOG_NAME not in repr(function)
|
|
|
|
|
assert _REVOKE_CATALOG_NAME not in str(function)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _assert_exact_revoke_request(
|
|
|
|
|
request: http.server.BaseHTTPRequestHandler,
|
|
|
|
|
raw: bytes,
|
|
|
|
|
body: dict[str, Any],
|
|
|
|
|
*,
|
|
|
|
|
expected_id: str,
|
|
|
|
|
) -> None:
|
|
|
|
|
assert request.command == "POST"
|
|
|
|
|
assert request.path == _REVOKE_PATH
|
|
|
|
|
assert "?" not in request.path
|
|
|
|
|
assert "remove" not in request.path
|
|
|
|
|
assert raw
|
|
|
|
|
assert body == {"function_id": expected_id}
|
|
|
|
|
assert set(body) == {"function_id"}
|
|
|
|
|
assert "name" not in body
|
|
|
|
|
assert "expected_current_function_id" not in body
|
|
|
|
|
assert "format_version" not in body
|
|
|
|
|
assert "function" not in body
|
|
|
|
|
assert "signature" not in body
|
|
|
|
|
assert "job_id" not in body
|
|
|
|
|
assert "idempotency_key" not in body
|
|
|
|
|
assert "user_version" not in body
|
|
|
|
|
assert "reason" not in body
|
|
|
|
|
assert "expiry" not in body
|
|
|
|
|
assert "force" not in body
|
|
|
|
|
request_id = request.headers.get("x-request-id")
|
|
|
|
|
assert isinstance(request_id, str) and request_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _assert_native_revoke_method_present() -> None:
|
|
|
|
|
assert hasattr(_native.Connection, "_revoke_function")
|
|
|
|
|
assert callable(getattr(_native.Connection, "_revoke_function"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _lookup_success_handler(
|
|
|
|
|
counters: dict[str, int],
|
|
|
|
|
*,
|
|
|
|
|
after_lookup: (
|
|
|
|
|
Callable[[http.server.BaseHTTPRequestHandler, bytes], None] | None
|
|
|
|
|
) = None,
|
|
|
|
|
):
|
|
|
|
|
"""Serve exact name lookup; optionally continue for revoke."""
|
|
|
|
|
|
|
|
|
|
def handler(request: http.server.BaseHTTPRequestHandler) -> None:
|
|
|
|
|
assert request.command == "POST"
|
|
|
|
|
raw = _read_body(request)
|
|
|
|
|
if request.path == _LOOKUP_PATH:
|
|
|
|
|
counters["lookup"] = counters.get("lookup", 0) + 1
|
|
|
|
|
body = json.loads(raw.decode("utf-8"))
|
|
|
|
|
assert body == {"name": _REVOKE_CATALOG_NAME}
|
|
|
|
|
request.send_response(200)
|
|
|
|
|
request.send_header("Content-Type", "application/json")
|
|
|
|
|
request.end_headers()
|
|
|
|
|
request.wfile.write(_lookup_success_body())
|
|
|
|
|
return
|
|
|
|
|
if after_lookup is not None:
|
|
|
|
|
after_lookup(request, raw)
|
|
|
|
|
return
|
|
|
|
|
counters["revoke"] = counters.get("revoke", 0) + 1
|
|
|
|
|
request.send_response(500)
|
|
|
|
|
request.end_headers()
|
|
|
|
|
request.wfile.write(b"unexpected revoke")
|
|
|
|
|
|
|
|
|
|
return handler
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _observe_current(db) -> lancedb.Function:
|
|
|
|
|
current = db.functions.get(_REVOKE_CATALOG_NAME)
|
|
|
|
|
_assert_exact_revoke_function(current)
|
|
|
|
|
assert not hasattr(current, "remove")
|
|
|
|
|
assert not hasattr(current, "delete")
|
|
|
|
|
assert not hasattr(current, "revoke")
|
|
|
|
|
return current
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _observe_current_async(db) -> lancedb.Function:
|
|
|
|
|
current = await db.functions.get(_REVOKE_CATALOG_NAME)
|
|
|
|
|
_assert_exact_revoke_function(current)
|
|
|
|
|
assert not hasattr(current, "remove")
|
|
|
|
|
assert not hasattr(current, "delete")
|
|
|
|
|
assert not hasattr(current, "revoke")
|
|
|
|
|
return current
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_native_connection_exposes_private_revoke_function():
|
|
|
|
|
_assert_native_revoke_method_present()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sync_remote_revoke_exact_body_path_request_id_returns_none():
|
|
|
|
|
_assert_native_revoke_method_present()
|
|
|
|
|
counters: dict[str, int] = {"lookup": 0, "revoke": 0}
|
|
|
|
|
revoke_attempts: list[dict[str, Any]] = []
|
|
|
|
|
|
|
|
|
|
def after_lookup(
|
|
|
|
|
request: http.server.BaseHTTPRequestHandler, payload: bytes
|
|
|
|
|
) -> None:
|
|
|
|
|
assert request.path == _REVOKE_PATH
|
|
|
|
|
counters["revoke"] += 1
|
|
|
|
|
body = json.loads(payload.decode("utf-8"))
|
|
|
|
|
revoke_attempts.append(
|
|
|
|
|
{
|
|
|
|
|
"request": request,
|
|
|
|
|
"raw": payload,
|
|
|
|
|
"body": body,
|
|
|
|
|
"request_id": request.headers.get("x-request-id"),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
# Illegal body on 204 must be ignored; success is status-driven only.
|
|
|
|
|
request.send_response(204)
|
|
|
|
|
request.send_header("Content-Type", "application/json")
|
|
|
|
|
request.end_headers()
|
|
|
|
|
request.wfile.write(
|
|
|
|
|
json.dumps(
|
|
|
|
|
{
|
|
|
|
|
_SENSITIVE_BODY_MARKER: True,
|
|
|
|
|
"message": _REVOKE_SERVER_MESSAGE_MARKER,
|
|
|
|
|
}
|
|
|
|
|
).encode("utf-8")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with _mock_remote_db(
|
|
|
|
|
_lookup_success_handler(counters, after_lookup=after_lookup)
|
|
|
|
|
) as db:
|
|
|
|
|
assert not hasattr(db, "revoke_function")
|
|
|
|
|
current = _observe_current(db)
|
|
|
|
|
assert counters["lookup"] == 1
|
|
|
|
|
assert counters["revoke"] == 0
|
|
|
|
|
|
|
|
|
|
result = db.functions.revoke(current)
|
|
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
assert counters["lookup"] == 1
|
|
|
|
|
assert counters["revoke"] == 1
|
|
|
|
|
assert len(revoke_attempts) == 1
|
|
|
|
|
attempt = revoke_attempts[0]
|
|
|
|
|
_assert_exact_revoke_request(
|
|
|
|
|
attempt["request"],
|
|
|
|
|
attempt["raw"],
|
|
|
|
|
attempt["body"],
|
|
|
|
|
expected_id=current.id,
|
|
|
|
|
)
|
|
|
|
|
assert attempt["body"]["function_id"] == current.id
|
|
|
|
|
_assert_exact_revoke_function(current)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_async_remote_revoke_exact_body_returns_none():
|
|
|
|
|
_assert_native_revoke_method_present()
|
|
|
|
|
counters: dict[str, int] = {"lookup": 0, "revoke": 0}
|
|
|
|
|
seen: dict[str, Any] = {}
|
|
|
|
|
|
|
|
|
|
def after_lookup(request: http.server.BaseHTTPRequestHandler, raw: bytes) -> None:
|
|
|
|
|
assert request.path == _REVOKE_PATH
|
|
|
|
|
counters["revoke"] += 1
|
|
|
|
|
seen["request"] = request
|
|
|
|
|
seen["raw"] = raw
|
|
|
|
|
seen["body"] = json.loads(raw.decode("utf-8"))
|
|
|
|
|
seen["request_id"] = request.headers.get("x-request-id")
|
|
|
|
|
request.send_response(204)
|
|
|
|
|
request.end_headers()
|
|
|
|
|
|
|
|
|
|
async with _mock_remote_db_async(
|
|
|
|
|
_lookup_success_handler(counters, after_lookup=after_lookup)
|
|
|
|
|
) as db:
|
|
|
|
|
assert not hasattr(db, "revoke_function")
|
|
|
|
|
current = await _observe_current_async(db)
|
|
|
|
|
assert counters["lookup"] == 1
|
|
|
|
|
assert counters["revoke"] == 0
|
|
|
|
|
result = await db.functions.revoke(current)
|
|
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
assert counters["lookup"] == 1
|
|
|
|
|
assert counters["revoke"] == 1
|
|
|
|
|
assert seen.get("raw")
|
|
|
|
|
_assert_exact_revoke_request(
|
|
|
|
|
seen["request"],
|
|
|
|
|
seen["raw"],
|
|
|
|
|
seen["body"],
|
|
|
|
|
expected_id=current.id,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_repeated_remote_revoke_204_both_return_none():
|
|
|
|
|
"""Two logical calls each receiving 204 both succeed (Python outcome only)."""
|
|
|
|
|
_assert_native_revoke_method_present()
|
|
|
|
|
counters: dict[str, int] = {"lookup": 0, "revoke": 0}
|
|
|
|
|
revoke_request_ids: list[str] = []
|
|
|
|
|
|
|
|
|
|
def after_lookup(
|
|
|
|
|
request: http.server.BaseHTTPRequestHandler, payload: bytes
|
|
|
|
|
) -> None:
|
|
|
|
|
assert request.path == _REVOKE_PATH
|
|
|
|
|
counters["revoke"] += 1
|
|
|
|
|
body = json.loads(payload.decode("utf-8"))
|
|
|
|
|
_assert_exact_revoke_request(
|
|
|
|
|
request, payload, body, expected_id=_REVOKE_FUNCTION_ID
|
|
|
|
|
)
|
|
|
|
|
request_id = request.headers.get("x-request-id")
|
|
|
|
|
assert isinstance(request_id, str) and request_id
|
|
|
|
|
revoke_request_ids.append(request_id)
|
|
|
|
|
request.send_response(204)
|
|
|
|
|
request.end_headers()
|
|
|
|
|
|
|
|
|
|
with _mock_remote_db(
|
|
|
|
|
_lookup_success_handler(counters, after_lookup=after_lookup)
|
|
|
|
|
) as db:
|
|
|
|
|
current = _observe_current(db)
|
|
|
|
|
assert counters["lookup"] == 1
|
|
|
|
|
assert counters["revoke"] == 0
|
|
|
|
|
|
|
|
|
|
first = db.functions.revoke(current)
|
|
|
|
|
second = db.functions.revoke(current)
|
|
|
|
|
|
|
|
|
|
assert first is None
|
|
|
|
|
assert second is None
|
|
|
|
|
assert counters["lookup"] == 1
|
|
|
|
|
assert counters["revoke"] == 2
|
|
|
|
|
assert len(revoke_request_ids) == 2
|
|
|
|
|
_assert_exact_revoke_function(current)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_after_revoke_name_and_id_lookup_still_return_function():
|
|
|
|
|
"""Revoke does not unlink names; SDK-visible sequence only, not Sophon proof."""
|
|
|
|
|
_assert_native_revoke_method_present()
|
|
|
|
|
counters: dict[str, int] = {
|
|
|
|
|
"lookup_name": 0,
|
|
|
|
|
"lookup_id": 0,
|
|
|
|
|
"revoke": 0,
|
|
|
|
|
}
|
|
|
|
|
revoked = {"yes": False}
|
|
|
|
|
|
|
|
|
|
def handler(request: http.server.BaseHTTPRequestHandler) -> None:
|
|
|
|
|
assert request.command == "POST"
|
|
|
|
|
raw = _read_body(request)
|
|
|
|
|
if request.path == _LOOKUP_PATH:
|
|
|
|
|
body = json.loads(raw.decode("utf-8"))
|
|
|
|
|
if "name" in body:
|
|
|
|
|
counters["lookup_name"] += 1
|
|
|
|
|
assert body == {"name": _REVOKE_CATALOG_NAME}
|
|
|
|
|
request.send_response(200)
|
|
|
|
|
request.send_header("Content-Type", "application/json")
|
|
|
|
|
request.end_headers()
|
|
|
|
|
request.wfile.write(_lookup_success_body())
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
counters["lookup_id"] += 1
|
|
|
|
|
assert body == {"function_id": _REVOKE_FUNCTION_ID}
|
|
|
|
|
request.send_response(200)
|
|
|
|
|
request.send_header("Content-Type", "application/json")
|
|
|
|
|
request.end_headers()
|
|
|
|
|
request.wfile.write(_lookup_success_body())
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
assert request.path == _REVOKE_PATH
|
|
|
|
|
counters["revoke"] += 1
|
|
|
|
|
body = json.loads(raw.decode("utf-8"))
|
|
|
|
|
_assert_exact_revoke_request(
|
|
|
|
|
request, raw, body, expected_id=_REVOKE_FUNCTION_ID
|
|
|
|
|
)
|
|
|
|
|
revoked["yes"] = True
|
|
|
|
|
request.send_response(204)
|
|
|
|
|
request.end_headers()
|
|
|
|
|
|
|
|
|
|
with _mock_remote_db(handler) as db:
|
|
|
|
|
current = _observe_current(db)
|
|
|
|
|
assert counters["lookup_name"] == 1
|
|
|
|
|
assert counters["lookup_id"] == 0
|
|
|
|
|
assert counters["revoke"] == 0
|
|
|
|
|
assert not revoked["yes"]
|
|
|
|
|
|
|
|
|
|
result = db.functions.revoke(current)
|
|
|
|
|
assert result is None
|
|
|
|
|
assert counters["lookup_name"] == 1
|
|
|
|
|
assert counters["revoke"] == 1
|
|
|
|
|
assert revoked["yes"]
|
|
|
|
|
|
|
|
|
|
by_name = db.functions.get(_REVOKE_CATALOG_NAME)
|
|
|
|
|
by_id = db.functions.get_by_id(_REVOKE_FUNCTION_ID)
|
|
|
|
|
|
|
|
|
|
assert counters["lookup_name"] == 2
|
|
|
|
|
assert counters["lookup_id"] == 1
|
|
|
|
|
assert counters["revoke"] == 1
|
|
|
|
|
_assert_exact_revoke_function(by_name)
|
|
|
|
|
_assert_exact_revoke_function(by_id)
|
|
|
|
|
assert by_name.id == current.id
|
|
|
|
|
assert by_id.id == current.id
|
|
|
|
|
assert by_name.parameters == current.parameters
|
|
|
|
|
assert by_id.parameters == current.parameters
|
|
|
|
|
assert by_name.output_type == current.output_type
|
|
|
|
|
assert by_id.output_type == current.output_type
|
|
|
|
|
assert by_name.output_nullable is current.output_nullable
|
|
|
|
|
assert by_id.output_nullable is current.output_nullable
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_explicit_name_or_function_not_found_is_function_error_payload_free():
|
|
|
|
|
_assert_native_revoke_method_present()
|
|
|
|
|
counters: dict[str, int] = {"lookup": 0, "revoke": 0}
|
|
|
|
|
body = {
|
|
|
|
|
"error_code": "name_or_function_not_found",
|
|
|
|
|
"message": (
|
|
|
|
|
f"{_REVOKE_SERVER_MESSAGE_MARKER} looks_like {_CONFLICTING_MESSAGE_CODE} "
|
|
|
|
|
"name_conflict"
|
|
|
|
|
),
|
|
|
|
|
"name": _REVOKE_CATALOG_NAME,
|
|
|
|
|
"function_id": _REVOKE_FUNCTION_ID,
|
|
|
|
|
_SENSITIVE_BODY_MARKER: True,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def after_lookup(
|
|
|
|
|
request: http.server.BaseHTTPRequestHandler, payload: bytes
|
|
|
|
|
) -> None:
|
|
|
|
|
assert request.path == _REVOKE_PATH
|
|
|
|
|
counters["revoke"] += 1
|
|
|
|
|
parsed = json.loads(payload.decode("utf-8"))
|
|
|
|
|
_assert_exact_revoke_request(
|
|
|
|
|
request, payload, parsed, expected_id=_REVOKE_FUNCTION_ID
|
|
|
|
|
)
|
|
|
|
|
request.send_response(404)
|
|
|
|
|
request.send_header("Content-Type", "application/json")
|
|
|
|
|
request.end_headers()
|
|
|
|
|
request.wfile.write(json.dumps(body).encode("utf-8"))
|
|
|
|
|
|
|
|
|
|
function_error = _function_error_cls()
|
|
|
|
|
with _mock_remote_db(
|
|
|
|
|
_lookup_success_handler(counters, after_lookup=after_lookup)
|
|
|
|
|
) as db:
|
|
|
|
|
current = _observe_current(db)
|
|
|
|
|
assert counters["lookup"] == 1
|
|
|
|
|
assert counters["revoke"] == 0
|
|
|
|
|
with pytest.raises(function_error) as exc_info:
|
|
|
|
|
db.functions.revoke(current)
|
|
|
|
|
|
|
|
|
|
assert counters["lookup"] == 1
|
|
|
|
|
assert counters["revoke"] == 1
|
|
|
|
|
err = exc_info.value
|
|
|
|
|
assert isinstance(err, function_error)
|
|
|
|
|
assert err.code == "name_or_function_not_found"
|
|
|
|
|
assert err.code != _CONFLICTING_MESSAGE_CODE
|
|
|
|
|
assert err.code != "name_conflict"
|
|
|
|
|
_assert_payload_free(err)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"label,status,response_body",
|
|
|
|
|
[
|
|
|
|
|
(
|
|
|
|
|
"200_with_body",
|
|
|
|
|
200,
|
|
|
|
|
{
|
|
|
|
|
"ok": True,
|
|
|
|
|
"message": _REVOKE_SERVER_MESSAGE_MARKER,
|
|
|
|
|
_SENSITIVE_BODY_MARKER: True,
|
|
|
|
|
"job_id": "must-not-infer-job",
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
"202_empty",
|
|
|
|
|
202,
|
|
|
|
|
f"{_REVOKE_SERVER_MESSAGE_MARKER} {_SENSITIVE_BODY_MARKER}",
|
|
|
|
|
),
|
|
|
|
|
("200_empty", 200, ""),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
def test_http_200_202_cannot_return_success(
|
|
|
|
|
label: str, status: int, response_body: object
|
|
|
|
|
):
|
|
|
|
|
del label
|
|
|
|
|
_assert_native_revoke_method_present()
|
|
|
|
|
counters: dict[str, int] = {"lookup": 0, "revoke": 0}
|
|
|
|
|
|
|
|
|
|
def after_lookup(
|
|
|
|
|
request: http.server.BaseHTTPRequestHandler, payload: bytes
|
|
|
|
|
) -> None:
|
|
|
|
|
assert request.path == _REVOKE_PATH
|
|
|
|
|
counters["revoke"] += 1
|
|
|
|
|
parsed = json.loads(payload.decode("utf-8"))
|
|
|
|
|
_assert_exact_revoke_request(
|
|
|
|
|
request, payload, parsed, expected_id=_REVOKE_FUNCTION_ID
|
|
|
|
|
)
|
|
|
|
|
request.send_response(status)
|
|
|
|
|
request.send_header("Content-Type", "application/json")
|
|
|
|
|
request.end_headers()
|
|
|
|
|
if isinstance(response_body, str):
|
|
|
|
|
request.wfile.write(response_body.encode("utf-8"))
|
|
|
|
|
else:
|
|
|
|
|
request.wfile.write(json.dumps(response_body).encode("utf-8"))
|
|
|
|
|
|
|
|
|
|
with _mock_remote_db(
|
|
|
|
|
_lookup_success_handler(counters, after_lookup=after_lookup)
|
|
|
|
|
) as db:
|
|
|
|
|
current = _observe_current(db)
|
|
|
|
|
assert counters["lookup"] == 1
|
|
|
|
|
assert counters["revoke"] == 0
|
|
|
|
|
with pytest.raises(HttpError) as exc_info:
|
|
|
|
|
db.functions.revoke(current)
|
|
|
|
|
|
|
|
|
|
assert counters["lookup"] == 1
|
|
|
|
|
assert counters["revoke"] == 1
|
|
|
|
|
err = exc_info.value
|
|
|
|
|
assert isinstance(err, HttpError)
|
|
|
|
|
assert not isinstance(err, _function_error_cls(required=False))
|
|
|
|
|
_assert_payload_free(err)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"bad_function",
|
|
|
|
|
[
|
|
|
|
|
_REVOKE_FUNCTION_ID,
|
|
|
|
|
{"id": _REVOKE_FUNCTION_ID},
|
|
|
|
|
object(),
|
|
|
|
|
123,
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
def test_raw_id_or_arbitrary_function_rejected_without_revoke(bad_function):
|
|
|
|
|
counters: dict[str, int] = {"lookup": 0, "revoke": 0}
|
|
|
|
|
|
|
|
|
|
with _mock_remote_db(_lookup_success_handler(counters)) as db:
|
|
|
|
|
# Observe a real handle separately so the bad-function path is isolated.
|
|
|
|
|
_ = _observe_current(db)
|
|
|
|
|
assert counters["lookup"] == 1
|
|
|
|
|
assert counters["revoke"] == 0
|
|
|
|
|
with pytest.raises(TypeError):
|
|
|
|
|
db.functions.revoke(bad_function)
|
|
|
|
|
|
|
|
|
|
assert counters["lookup"] == 1
|
|
|
|
|
assert counters["revoke"] == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_local_sync_revoke_not_implemented_without_table_mutation(tmp_path):
|
|
|
|
|
_assert_native_revoke_method_present()
|
|
|
|
|
counters: dict[str, int] = {"lookup": 0, "revoke": 0}
|
|
|
|
|
with _mock_remote_db(_lookup_success_handler(counters)) as remote_db:
|
|
|
|
|
current = _observe_current(remote_db)
|
|
|
|
|
assert counters["lookup"] == 1
|
|
|
|
|
assert counters["revoke"] == 0
|
|
|
|
|
assert type(current) is lancedb.Function
|
|
|
|
|
|
|
|
|
|
db = lancedb.connect(tmp_path)
|
|
|
|
|
before = db.list_tables().tables
|
|
|
|
|
assert before == []
|
|
|
|
|
assert not hasattr(db, "revoke_function")
|
|
|
|
|
|
|
|
|
|
with pytest.raises(NotImplementedError):
|
|
|
|
|
db.functions.revoke(current)
|
|
|
|
|
|
|
|
|
|
assert db.list_tables().tables == before
|
|
|
|
|
_close_db(db)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_local_async_revoke_not_implemented_without_table_mutation(tmp_path):
|
|
|
|
|
_assert_native_revoke_method_present()
|
|
|
|
|
counters: dict[str, int] = {"lookup": 0, "revoke": 0}
|
|
|
|
|
with _mock_remote_db(_lookup_success_handler(counters)) as remote_db:
|
|
|
|
|
current = _observe_current(remote_db)
|
|
|
|
|
assert counters["lookup"] == 1
|
|
|
|
|
assert counters["revoke"] == 0
|
|
|
|
|
assert type(current) is lancedb.Function
|
|
|
|
|
|
|
|
|
|
db = await lancedb.connect_async(tmp_path)
|
|
|
|
|
before = (await db.list_tables()).tables
|
|
|
|
|
assert before == []
|
|
|
|
|
assert not hasattr(db, "revoke_function")
|
|
|
|
|
|
|
|
|
|
with pytest.raises(NotImplementedError):
|
|
|
|
|
await db.functions.revoke(current)
|
|
|
|
|
|
|
|
|
|
assert (await db.list_tables()).tables == before
|
|
|
|
|
db.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("keyword", _DELETED_REVOKE_KEYWORDS)
|
|
|
|
|
def test_revoke_rejects_overdesigned_kwargs(keyword):
|
|
|
|
|
counters: dict[str, int] = {"lookup": 0, "revoke": 0}
|
|
|
|
|
|
|
|
|
|
with _mock_remote_db(_lookup_success_handler(counters)) as db:
|
|
|
|
|
current = _observe_current(db)
|
|
|
|
|
assert counters["lookup"] == 1
|
|
|
|
|
assert counters["revoke"] == 0
|
|
|
|
|
with pytest.raises(TypeError):
|
|
|
|
|
db.functions.revoke(current, **{keyword: True})
|
|
|
|
|
|
|
|
|
|
assert counters["lookup"] == 1
|
|
|
|
|
assert counters["revoke"] == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_no_direct_revoke_methods_and_function_has_no_revoke_facade_private():
|
|
|
|
|
counters: dict[str, int] = {"lookup": 0, "revoke": 0}
|
|
|
|
|
|
|
|
|
|
with _mock_remote_db(_lookup_success_handler(counters)) as db:
|
|
|
|
|
current = _observe_current(db)
|
|
|
|
|
assert not hasattr(db, "revoke_function")
|
|
|
|
|
assert not hasattr(current, "remove")
|
|
|
|
|
assert not hasattr(current, "delete")
|
|
|
|
|
assert not hasattr(current, "revoke")
|
|
|
|
|
assert callable(getattr(db.functions, "revoke", None))
|
|
|
|
|
assert not hasattr(lancedb, "_SyncFunctions")
|
|
|
|
|
assert not hasattr(lancedb, "_AsyncFunctions")
|
|
|
|
|
assert type(db.functions).__name__.startswith("_")
|
|
|
|
|
assert "_SyncFunctions" not in getattr(lancedb, "__all__", [])
|
|
|
|
|
assert "_AsyncFunctions" not in getattr(lancedb, "__all__", [])
|
|
|
|
|
|
|
|
|
|
assert counters["lookup"] == 1
|
|
|
|
|
assert counters["revoke"] == 0
|