diff --git a/python/python/lancedb/db.py b/python/python/lancedb/db.py index 9ed772e1c..b6dfe8fb8 100644 --- a/python/python/lancedb/db.py +++ b/python/python/lancedb/db.py @@ -823,8 +823,26 @@ class DBConnection(EnforceOverrides): @property def uri(self) -> str: + """The URI of this database.""" return self._uri + def _all_table_names(self) -> Generator[str, None, None]: + page_token = None + while True: + response = self.list_tables(page_token=page_token) + yield from response.tables + page_token = response.page_token + if not page_token: + return + + def __len__(self) -> int: + """Return the number of tables in the root namespace across all pages.""" + return sum(1 for _ in self._all_table_names()) + + def __contains__(self, name: str) -> bool: + """Return whether the root namespace contains the named table.""" + return name in self._all_table_names() + def namespace_client(self) -> LanceNamespace: """Get the equivalent namespace client for this connection. @@ -1609,21 +1627,6 @@ class LanceDBConnection(DBConnection): ) ) - def _all_table_names(self) -> Generator[str, None, None]: - page_token = None - while True: - response = self.list_tables(page_token=page_token) - yield from response.tables - page_token = response.page_token - if not page_token: - return - - def __len__(self) -> int: - return sum(1 for _ in self._all_table_names()) - - def __contains__(self, name: str) -> bool: - return name in self._all_table_names() - @override def create_table( self, diff --git a/python/python/lancedb/remote/db.py b/python/python/lancedb/remote/db.py index c11e01ff4..4ffd3ac4a 100644 --- a/python/python/lancedb/remote/db.py +++ b/python/python/lancedb/remote/db.py @@ -47,7 +47,7 @@ from ..secrets import EnvVarSecret, SecretInfo from ..view import ViewDescription if TYPE_CHECKING: - from .._lancedb import JobInfo + from .._lancedb import JobInfo, Session from ..embeddings import EmbeddingFunctionConfig from lance_namespace import ( LanceNamespace, @@ -240,6 +240,23 @@ class RemoteDBConnection(DBConnection): def __repr__(self) -> str: return f"RemoteConnect(name={self.db_name})" + @property + @override + def uri(self) -> str: + return self._conn.uri + + @property + def session(self) -> Optional["Session"]: + """Remote connections do not expose a local cache session.""" + raise NotImplementedError("session is not supported by remote connections") + + @property + def read_consistency_interval(self) -> Optional[timedelta]: + """Remote connections do not expose a local read consistency interval.""" + raise NotImplementedError( + "read_consistency_interval is not supported by remote connections" + ) + @override def serialize(self) -> str: if getattr(self, "_catalog_oauth", False): diff --git a/python/python/lancedb/remote/table.py b/python/python/lancedb/remote/table.py index faf15c987..6b7835470 100644 --- a/python/python/lancedb/remote/table.py +++ b/python/python/lancedb/remote/table.py @@ -192,6 +192,18 @@ class RemoteTable(Table): """The name of the table""" return self._name + @property + def namespace(self) -> List[str]: + return self._namespace_path + + @deprecation.deprecated( + deprecated_in="0.33.1", + current_version=__version__, + details="Use update_field_metadata() instead.", + ) + def replace_field_metadata(self, field_name: str, new_metadata: Dict[str, str]): + LOOP.run(self._table.replace_field_metadata(field_name, new_metadata)) + def __repr__(self) -> str: return f"RemoteTable({self.db_name}.{self.name})" diff --git a/python/python/lancedb/table.py b/python/python/lancedb/table.py index d2ff02683..f1e538c14 100644 --- a/python/python/lancedb/table.py +++ b/python/python/lancedb/table.py @@ -1360,6 +1360,16 @@ class Table(ABC): """The name of this Table""" raise NotImplementedError("name is not supported for this table type") + @property + def namespace(self) -> List[str]: + """Return the namespace path of the table (empty for the root namespace).""" + return [] + + @property + def id(self) -> str: + """Return the full identifier of the table (namespace$name).""" + return "$".join(self.namespace + [self.name]) + @property @abstractmethod def version(self) -> int: @@ -3106,6 +3116,29 @@ class Table(ABC): version: the new table version after the update. """ + @deprecation.deprecated( + deprecated_in="0.33.1", + current_version=__version__, + details="Use update_field_metadata() instead.", + ) + def replace_field_metadata(self, field_name: str, new_metadata: Dict[str, str]): + """Replace all metadata of a field in the schema. + + .. deprecated:: 0.33.1 + Use [update_field_metadata][lancedb.table.Table.update_field_metadata] + with ``replace=True`` instead. + + Parameters + ---------- + field_name: str + The name of the field whose metadata is replaced. + new_metadata: dict + The new metadata, replacing all existing keys. + """ + self.update_field_metadata( + {"path": field_name, "metadata": new_metadata, "replace": True} + ) + @abstractmethod def drop_columns(self, columns: Iterable[str]) -> DropColumnsResult: """ @@ -3315,13 +3348,6 @@ class LanceTable(Table): """Return the namespace path of the table.""" return self._namespace_path - @property - def id(self) -> str: - """Return the full identifier of the table (namespace$name).""" - if self._namespace_path: - return "$".join(self._namespace_path + [self.name]) - return self.name - @classmethod async def from_inner(cls, tbl: LanceDBTable): from .db import AsyncConnection, LanceDBConnection diff --git a/python/python/tests/test_sync_api_parity.py b/python/python/tests/test_sync_api_parity.py new file mode 100644 index 000000000..a12efd0c9 --- /dev/null +++ b/python/python/tests/test_sync_api_parity.py @@ -0,0 +1,170 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The LanceDB Authors + +from datetime import timedelta +import json +from urllib.parse import parse_qs, urlparse + +import lancedb +from lancedb.table import Table +import pytest + +from test_remote_db import mock_lancedb_connection + + +@pytest.fixture +def legacy_table(): + """Implement the pre-existing abstract API without the two new members.""" + + def unsupported(self, *args, **kwargs): + raise NotImplementedError + + def update_field_metadata(self, updates): + self.metadata_updates.append(updates) + return object() # The deprecated wrapper must not expose this result. + + # Supply concrete implementations through normal ABC class creation. Leave + # namespace and replace_field_metadata inherited, as an old subclass would. + implementations = { + name: unsupported + for name in Table.__abstractmethods__ + if name not in {"namespace", "replace_field_metadata"} + } + implementations.update( + name=property(lambda self: "legacy"), + update_field_metadata=update_field_metadata, + ) + legacy_class = type("LegacyTable", (Table,), implementations) + table = legacy_class() + table.metadata_updates = [] + return table + + +def test_legacy_table_namespace_and_id(legacy_table): + assert legacy_table.namespace == [] + assert legacy_table.id == "legacy" + namespace = legacy_table.namespace + namespace.append("changed") + assert legacy_table.namespace == [] + assert legacy_table.id == "legacy" + + +def test_legacy_table_replace_field_metadata(legacy_table): + with pytest.warns(DeprecationWarning, match="update_field_metadata"): + result = legacy_table.replace_field_metadata("nested.field", {"a": "b"}) + assert result is None + assert legacy_table.metadata_updates == [ + {"path": "nested.field", "metadata": {"a": "b"}, "replace": True} + ] + + +def respond(request, body): + request.send_response(200) + request.send_header("Content-Type", "application/json") + request.end_headers() + request.wfile.write(json.dumps(body).encode()) + + +def test_remote_uri(): + with mock_lancedb_connection(lambda request: respond(request, {})) as db: + assert db.uri == "db://dev" + + +@pytest.mark.parametrize("attribute", ["session", "read_consistency_interval"]) +def test_remote_unsupported_properties(attribute): + with mock_lancedb_connection(lambda request: respond(request, {})) as db: + with pytest.raises(NotImplementedError, match=attribute): + getattr(db, attribute) + + +@pytest.mark.parametrize("tables", [[], ["first", "last"]]) +def test_remote_len_and_contains_follow_pages(tables): + requests = [] + + def handler(request): + requests.append(request.path) + query = parse_qs(urlparse(request.path).query) + if tables and "page_token" not in query: + respond(request, {"tables": [tables[0]], "page_token": "opaque-next"}) + else: + if tables: + assert query["page_token"] == ["opaque-next"] + respond(request, {"tables": tables[1:]}) + + with mock_lancedb_connection(handler) as db: + assert len(db) == len(tables) + assert ("last" in db) == bool(tables) + assert "missing" not in db + requests.clear() + assert ("first" in db) == bool(tables) + assert len(requests) == 1 + + +@pytest.mark.parametrize("namespace", [[], ["parent", "child"]]) +def test_remote_table_identifiers(namespace): + def handler(request): + respond(request, {"version": 1, "schema": {"fields": []}}) + + with mock_lancedb_connection(handler) as db: + table = db.open_table("rfm", namespace_path=namespace) + assert table.namespace == namespace + assert table.id == "$".join(namespace + ["rfm"]) + + +def test_remote_replace_field_metadata(): + updates = [] + + def handler(request): + if request.path.endswith("/describe/"): + respond(request, {"version": 1, "schema": {"fields": []}}) + else: + assert request.path == "/v1/table/rfm/update_field_metadata/" + body = request.rfile.read(int(request.headers["Content-Length"])) + updates.append(json.loads(body)) + respond(request, {"version": 2}) + + with mock_lancedb_connection(handler) as db: + table = db.open_table("rfm") + with pytest.warns(DeprecationWarning, match="update_field_metadata"): + assert table.replace_field_metadata("id", {"a": "b"}) is None + assert updates == [ + {"updates": [{"path": "id", "metadata": {"a": "b"}, "replace": True}]} + ] + + +@pytest.mark.parametrize("custom_session", [False, True]) +def test_local_properties_and_metadata(tmp_path, custom_session): + supplied = lancedb.Session() if custom_session else None + db = lancedb.connect( + tmp_path, session=supplied, read_consistency_interval=timedelta(seconds=5) + ) + session = db.session + assert session is supplied + before = session.size_bytes if session is not None else 0 + table = db.create_table("rfm", [{"id": i} for i in range(3)]) + assert table.to_arrow().num_rows == 3 + if supplied is not None: + assert supplied.size_bytes > before + assert db.read_consistency_interval == timedelta(seconds=5) + assert table.namespace == [] + assert table.id == "rfm" + with pytest.warns(DeprecationWarning): + table.replace_field_metadata("id", {"old": "value"}) + with pytest.warns(DeprecationWarning): + table.replace_field_metadata("id", {"a": "b"}) + assert table.schema.field("id").metadata == {b"a": b"b"} + + +def test_namespace_connection_properties_remain_writable(tmp_path): + session = lancedb.Session() + interval = timedelta(seconds=5) + with lancedb.connect_namespace( + "dir", + {"root": str(tmp_path)}, + session=session, + read_consistency_interval=interval, + ) as db: + assert db.session is session + assert db.read_consistency_interval == interval + db.read_consistency_interval = timedelta(0) + assert db.read_consistency_interval == timedelta(0)