From f65bf89c988706e65d77b61fd75483b738838571 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Wed, 12 Aug 2026 08:22:49 +0800 Subject: [PATCH] feat(python): add exact function revocation --- python/python/lancedb/_functions.py | 8 + python/python/lancedb/_lancedb.pyi | 1 + python/python/lancedb/db.py | 16 + python/python/lancedb/remote/db.py | 4 + .../tests/test_first_class_function_lookup.py | 1 - .../tests/test_first_class_function_revoke.py | 728 ++++++++++++++++++ python/src/connection.rs | 18 + 7 files changed, 775 insertions(+), 1 deletion(-) create mode 100644 python/python/tests/test_first_class_function_revoke.py diff --git a/python/python/lancedb/_functions.py b/python/python/lancedb/_functions.py index 49938a85a..17eac52fc 100644 --- a/python/python/lancedb/_functions.py +++ b/python/python/lancedb/_functions.py @@ -59,6 +59,10 @@ class _SyncFunctions: """Conditionally remove a Function catalog name binding.""" return self._connection._remove_function_name(name, current) + def revoke(self, function: Function) -> None: + """Revoke an exact immutable Function by administrator set-bit.""" + return self._connection._revoke_function(function) + class _AsyncFunctions: """Asynchronous `async_db.functions` facade.""" @@ -98,3 +102,7 @@ class _AsyncFunctions: async def remove(self, name: str, current: Function) -> None: """Conditionally remove a Function catalog name binding.""" return await self._connection._remove_function_name(name, current) + + async def revoke(self, function: Function) -> None: + """Revoke an exact immutable Function by administrator set-bit.""" + return await self._connection._revoke_function(function) diff --git a/python/python/lancedb/_lancedb.pyi b/python/python/lancedb/_lancedb.pyi index 63f4ff4f0..7798bdf73 100644 --- a/python/python/lancedb/_lancedb.pyi +++ b/python/python/lancedb/_lancedb.pyi @@ -162,6 +162,7 @@ class Connection(object): async def _lookup_function_by_name(self, name: str) -> Function: ... async def _lookup_function_by_id(self, function_id: str) -> Function: ... async def _remove_function_name(self, name: str, current: Function) -> None: ... + async def _revoke_function(self, function: Function) -> None: ... async def create_table( self, name: str, diff --git a/python/python/lancedb/db.py b/python/python/lancedb/db.py index 86bdca674..1c26c6cc2 100644 --- a/python/python/lancedb/db.py +++ b/python/python/lancedb/db.py @@ -710,6 +710,15 @@ class DBConnection(EnforceOverrides): "function name removal is not supported for this connection type" ) + def _revoke_function(self, function: "Function") -> None: + """Revoke an exact immutable Function via the native connection. + + Connection subclasses that share the native Connection override this hook. + """ + raise NotImplementedError( + "function revocation is not supported for this connection type" + ) + class LanceDBConnection(DBConnection): """ @@ -1351,6 +1360,10 @@ class LanceDBConnection(DBConnection): def _remove_function_name(self, name: str, current: "Function") -> None: return LOOP.run(self._conn._remove_function_name(name, current)) + @override + def _revoke_function(self, function: "Function") -> None: + return LOOP.run(self._conn._revoke_function(function)) + @override def namespace_client(self) -> LanceNamespace: """Get the equivalent namespace client for this connection. @@ -2123,6 +2136,9 @@ class AsyncConnection(object): async def _remove_function_name(self, name: str, current: "Function") -> None: return await self._inner._remove_function_name(name, current) + async def _revoke_function(self, function: "Function") -> None: + return await self._inner._revoke_function(function) + async def namespace_client(self) -> LanceNamespace: """Get the equivalent namespace client for this connection. diff --git a/python/python/lancedb/remote/db.py b/python/python/lancedb/remote/db.py index 38f12d7c0..c535e6194 100644 --- a/python/python/lancedb/remote/db.py +++ b/python/python/lancedb/remote/db.py @@ -760,6 +760,10 @@ class RemoteDBConnection(DBConnection): def _remove_function_name(self, name: str, current: "Function") -> None: return LOOP.run(self._conn._remove_function_name(name, current)) + @override + def _revoke_function(self, function: "Function") -> None: + return LOOP.run(self._conn._revoke_function(function)) + @override def namespace_client(self) -> LanceNamespace: """Get the equivalent namespace client for this connection. diff --git a/python/python/tests/test_first_class_function_lookup.py b/python/python/tests/test_first_class_function_lookup.py index b34186829..8df93cd7c 100644 --- a/python/python/tests/test_first_class_function_lookup.py +++ b/python/python/tests/test_first_class_function_lookup.py @@ -618,7 +618,6 @@ def test_no_direct_db_lookup_methods_and_no_deleted_keywords(): assert not hasattr(db, "get_function") assert not hasattr(db.functions, "get_by_name") assert not hasattr(db.functions, "list") - assert not hasattr(db.functions, "revoke") for keyword in _DELETED_LOOKUP_KEYWORDS: with pytest.raises(TypeError): diff --git a/python/python/tests/test_first_class_function_revoke.py b/python/python/tests/test_first_class_function_revoke.py new file mode 100644 index 000000000..cb01abad9 --- /dev/null +++ b/python/python/tests/test_first_class_function_revoke.py @@ -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 diff --git a/python/src/connection.rs b/python/src/connection.rs index a32458cda..9dc942ec2 100644 --- a/python/src/connection.rs +++ b/python/src/connection.rs @@ -687,6 +687,24 @@ impl Connection { Ok(None::<()>) }) } + + /// Revoke an exact immutable Function by administrator set-bit. + /// + /// Clones the observed native [`crate::function::Function`] once and + /// delegates to Rust [`lancedb::Connection::revoke_function`]. + pub fn _revoke_function<'py>( + self_: PyRef<'py, Self>, + function: Bound<'_, crate::function::Function>, + ) -> PyResult> { + let inner = self_.get_inner()?.clone(); + let function = function.get().inner().clone(); + future_into_py(self_.py(), async move { + inner.revoke_function(&function).await.infer_error()?; + // `()` maps to an empty Python tuple via IntoPyObject; return Option + // so the async bridge yields exact Python None. + Ok(None::<()>) + }) + } } #[pyfunction]