fix(python): implement remote namespace and table existence checks (#4725)

Remote Python connections inherited the unsupported-operation stubs for
`namespace_exists` and `table_exists`, so both raised
`NotImplementedError` even when namespace and table operations worked.

Add the missing remote overrides, async methods, and PyO3 bindings using
the existing namespace describe and table open paths. Only an explicit
not-found result returns `False`; permission, transport, and server
errors propagate. This preserves authentication, dynamic headers,
retries, and nested namespace handling, and rechecks cached tables
before reporting that they exist.

The async namespace helper also recognizes native `NamespaceNotFound`
errors so missing local namespaces return `False`. The table and
namespace guides document Python remote support and distinguish it from
the unsupported `exists` routes used by direct REST namespace clients.

Validation:
- All 40 focused regression cases pass, covering successful and missing
lookups, local and remote namespaces, dynamic headers, permission/server
errors, invalid table identifiers, and cached tables that disappear.
- The original inherited stubs reproduce the reported
`NotImplementedError`; the local namespace regression reproduced the
native missing-namespace `RuntimeError` before its fix.
- Full remote connection suite: 255 passed. Local namespace tests: 14
passed.
- `cargo clippy --quiet -p lancedb-python --features remote --lib`,
`cargo fmt --all`, Python Ruff lint, and Python formatting checks pass.
- Root `ruff check .` reports 48 existing errors in documentation
examples under `docs/web-tests`; the Python subtree passes and those
unrelated files are unchanged.

Fixes #4722

<!-- lance-gatekeeper-fix:v1 agent=4d369a0681756fdb5a8b35853d7f5607
generation=1 -->

---------

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
This commit is contained in:
lancedb-gatefixer[bot]
2026-10-28 16:08:43 -07:00
committed by GitHub
parent fc5170cbed
commit 741cb9ad4e
8 changed files with 257 additions and 11 deletions
+2
View File
@@ -137,6 +137,8 @@ class Connection(object):
self,
namespace_path: List[str],
) -> DescribeNamespaceResponse: ...
async def namespace_exists(self, namespace_id: List[str]) -> bool: ...
async def table_exists(self, table_id: List[str]) -> bool: ...
def list_tables(
self,
namespace_path: Optional[List[str]] = None,
+43
View File
@@ -2436,6 +2436,49 @@ class AsyncConnection(object):
result = await self._inner.describe_namespace(namespace_path)
return DescribeNamespaceResponse(**result)
async def namespace_exists(self, namespace_id: List[str]) -> bool:
"""Check if a namespace exists.
Parameters
----------
namespace_id: List[str]
The namespace identifier to check. An empty list represents root.
Returns
-------
bool
True if the namespace exists, False if it is not found.
Other errors, including permission errors, are propagated.
Examples
--------
>>> async def check_namespace(conn):
... return await conn.namespace_exists(["analytics"])
"""
return await self._inner.namespace_exists(namespace_id)
async def table_exists(self, table_id: List[str]) -> bool:
"""Check if a table exists.
Parameters
----------
table_id: List[str]
The full identifier, including namespace segments and table name.
Must contain at least the table name.
Returns
-------
bool
True if the table exists, False if it is not found.
Other errors, including permission errors, are propagated.
Examples
--------
>>> async def check_table(conn):
... return await conn.table_exists(["analytics", "events"])
"""
return await self._inner.table_exists(table_id)
def list_tables(
self,
namespace_path: Optional[List[str]] = None,
+30
View File
@@ -390,6 +390,36 @@ class RemoteDBConnection(DBConnection):
"""
return LOOP.run(self._conn.describe_namespace(namespace_path=namespace_path))
@override
def namespace_exists(self, namespace_id: List[str]) -> bool:
"""Check if a namespace exists.
``namespace_id`` contains the namespace segments; an empty list represents
root. Returns False only if the namespace is not found. Other errors,
including permission errors, are propagated.
Examples
--------
>>> def check_namespace(conn):
... return conn.namespace_exists(["analytics"])
"""
return LOOP.run(self._conn.namespace_exists(namespace_id))
@override
def table_exists(self, table_id: List[str]) -> bool:
"""Check if a table exists.
``table_id`` contains the namespace segments followed by the table name.
Returns False only if the table is not found. Other errors, including
permission errors, are propagated.
Examples
--------
>>> def check_table(conn):
... return conn.table_exists(["analytics", "events"])
"""
return LOOP.run(self._conn.table_exists(table_id))
@override
def list_tables(
self,
+18
View File
@@ -1149,6 +1149,24 @@ def test_local_namespace_operations(tmp_path):
assert db.list_namespaces().namespaces == []
@pytest.mark.asyncio
async def test_local_async_namespace_exists(tmp_path):
db = await lancedb.connect_async(tmp_path)
await db.create_namespace(["existing"])
await db.create_namespace(["existing", "nested"])
assert await db.namespace_exists([]) is True
assert await db.namespace_exists(["existing"]) is True
assert await db.namespace_exists(["existing", "nested"]) is True
assert await db.namespace_exists(["missing"]) is False
assert await db.namespace_exists(["existing", "missing"]) is False
assert await db.namespace_exists(["missing", "nested"]) is False
await db.drop_namespace(["existing", "nested"])
assert await db.namespace_exists(["existing", "nested"]) is False
assert await db.namespace_exists(["existing"]) is True
def test_local_sync_namespace_uses_rust_without_python_client(tmp_path, monkeypatch):
"""Sync local namespace operations should avoid the Python namespace client."""
db = lancedb.connect(tmp_path)
+97 -1
View File
@@ -54,7 +54,7 @@ def test_connect_rejects_invalid_cloud_dns_hostname(db_name):
@contextlib.contextmanager
def mock_lancedb_connection(handler):
def mock_lancedb_connection(handler, **client_config):
with http.server.HTTPServer(
("localhost", 0), make_mock_http_handler(handler)
) as server:
@@ -71,6 +71,7 @@ def mock_lancedb_connection(handler):
"timeout_config": {
"connect_timeout": 1,
},
**client_config,
},
)
@@ -110,6 +111,101 @@ async def mock_lancedb_connection_async(handler, **client_config):
handle.join()
@pytest.fixture
def existence_handler(method, identifier, status):
def handler(request):
resource = "namespace" if method == "namespace_exists" else "table"
resource_id = "$".join(identifier) or "$"
suffix = "/" if resource == "table" else ""
assert request.command == "POST"
assert request.path == f"/v1/{resource}/{resource_id}/describe{suffix}"
assert request.headers["x-api-key"] == "fake"
assert request.headers["X-Exists-Test"] == "present"
request.send_response(status)
request.send_header("Content-Type", "application/json")
request.end_headers()
request.wfile.write(b"{}" if status == 200 else b"lookup failed")
return handler
@pytest.mark.parametrize(
"method,identifier",
[
("namespace_exists", []),
("namespace_exists", ["ns"]),
("namespace_exists", ["parent", "ns"]),
("table_exists", ["table"]),
("table_exists", ["parent", "ns", "table"]),
],
)
@pytest.mark.parametrize("status", [200, 404, 403, 507])
def test_remote_exists(method, identifier, status, existence_handler):
from lancedb.remote.header import StaticHeaderProvider
provider = StaticHeaderProvider({"X-Exists-Test": "present"})
with mock_lancedb_connection(existence_handler, header_provider=provider) as db:
if status in (200, 404):
assert getattr(db, method)(identifier) is (status == 200)
else:
with pytest.raises(HttpError) as exc_info:
getattr(db, method)(identifier)
assert exc_info.value.status_code == status
@pytest.mark.asyncio
@pytest.mark.parametrize(
"method,identifier",
[
("namespace_exists", []),
("namespace_exists", ["parent", "ns"]),
("table_exists", ["table"]),
("table_exists", ["parent", "ns", "table"]),
],
)
@pytest.mark.parametrize("status", [200, 404, 403, 507])
async def test_async_remote_exists(method, identifier, status, existence_handler):
from lancedb.remote.header import StaticHeaderProvider
provider = StaticHeaderProvider({"X-Exists-Test": "present"})
async with mock_lancedb_connection_async(
existence_handler, header_provider=provider
) as db:
if status in (200, 404):
assert await getattr(db, method)(identifier) is (status == 200)
else:
with pytest.raises(HttpError) as exc_info:
await getattr(db, method)(identifier)
assert exc_info.value.status_code == status
@pytest.mark.parametrize("table_id", [[], ["invalid/name"]])
def test_remote_table_exists_rejects_invalid_identifier(table_id):
requests = []
with mock_lancedb_connection(lambda request: requests.append(request.path)) as db:
with pytest.raises(ValueError):
db.table_exists(table_id)
assert requests == []
def test_remote_table_exists_rechecks_cached_table():
requests = []
def handler(request):
requests.append(request.path)
request.send_response(200 if len(requests) == 1 else 404)
request.send_header("Content-Type", "application/json")
request.end_headers()
request.wfile.write(b"{}")
with mock_lancedb_connection(handler) as db:
table_id = ["ns", "table"]
assert db.table_exists(table_id) is True
assert db.table_exists(table_id) is False
assert table_id == ["ns", "table"]
assert requests == ["/v1/table/ns$table/describe/"] * 2
def paginated_table_listing_handler(request):
url = urlparse(request.path)
assert url.path in {
+47
View File
@@ -711,6 +711,53 @@ impl Connection {
})
}
pub fn namespace_exists(
self_: PyRef<'_, Self>,
namespace_id: Vec<String>,
) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.get_inner()?.clone();
future_into_py(self_.py(), async move {
let request = lance_namespace::models::DescribeNamespaceRequest {
id: Some(namespace_id),
..Default::default()
};
match inner.describe_namespace(request).await {
Ok(_) => Ok(true),
Err(lancedb::Error::Http {
status_code: Some(status),
..
}) if status.as_u16() == 404 => Ok(false),
Err(lancedb::Error::Lance {
source: lance_core::Error::Namespace { ref source, .. },
}) if matches!(
source.downcast_ref::<lance_namespace::NamespaceError>(),
Some(lance_namespace::NamespaceError::NamespaceNotFound { .. })
) =>
{
Ok(false)
}
Err(err) => Err(err).infer_error(),
}
})
}
pub fn table_exists(
self_: PyRef<'_, Self>,
mut table_id: Vec<String>,
) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.get_inner()?.clone();
let name = table_id
.pop()
.ok_or_else(|| PyValueError::new_err("table_id must include a table name"))?;
future_into_py(self_.py(), async move {
match inner.open_table(name).namespace(table_id).execute().await {
Ok(_) => Ok(true),
Err(lancedb::Error::TableNotFound { .. }) => Ok(false),
Err(err) => Err(err).infer_error(),
}
})
}
#[pyo3(signature = (namespace_path=None, page_token=None, limit=None))]
pub fn list_tables(
&self,