Skip to content

Commit 2cf68fb

Browse files
jackye1995claude
andcommitted
fix: align Hive2Namespace with hive.md specification
Updates the Hive2Namespace implementation to be consistent with the documented specification in hive.md: Configuration changes: - Use 'root' instead of 'warehouse' for storage root location - Add 'ugi' to configuration properties documentation - Support 'client.pool-size' and 'storage.*' properties Root namespace handling: - list_namespaces: Only list from root namespace - describe_namespace: Support describing root namespace - create_namespace: Reject creating root (already exists) - drop_namespace: Reject dropping root namespace - namespace_exists: Root namespace always exists - list_tables: Return empty list for root namespace Table metadata: - Use 'table_type' key (not 'lance.table_type') per spec - Set 'managed_by' property (default: 'storage') - Use case-insensitive matching for 'lance' table type - Include 'version' key for table version tracking 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 5dcc72c commit 2cf68fb

2 files changed

Lines changed: 125 additions & 44 deletions

File tree

python/lance_namespace/src/lance_namespace/hive.py

Lines changed: 88 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
# Connect to Hive Metastore
1515
namespace = connect("hive2", {
1616
"uri": "thrift://localhost:9083",
17-
"warehouse": "/user/hive/warehouse",
17+
"root": "/my/dir", # Or "s3://bucket/prefix"
1818
"ugi": "user:group1,group2" # Optional user/group info
1919
})
2020
@@ -48,8 +48,10 @@
4848
4949
Configuration Properties:
5050
uri (str): Hive Metastore Thrift URI (e.g., "thrift://localhost:9083")
51-
warehouse (str): Warehouse directory path (default: "/user/hive/warehouse")
51+
root (str): Storage root location of the lakehouse on Hive catalog (default: current working directory)
5252
ugi (str): Optional User Group Information for authentication (format: "user:group1,group2")
53+
client.pool-size (int): Size of the HMS client connection pool (default: 3)
54+
storage.* (str): Additional storage configurations to access table
5355
"""
5456
from typing import Dict, List, Optional, Any
5557
from urllib.parse import urlparse, unquote
@@ -120,9 +122,11 @@
120122

121123
logger = logging.getLogger(__name__)
122124

123-
# Table properties used by Lance
124-
LANCE_TABLE_TYPE = "lance.table_type"
125-
LANCE_TABLE_FORMAT = "LANCE"
125+
# Table properties used by Lance (per hive.md specification)
126+
TABLE_TYPE_KEY = "table_type" # Case insensitive
127+
LANCE_TABLE_FORMAT = "lance" # Case insensitive
128+
MANAGED_BY_KEY = "managed_by" # Case insensitive, values: "storage" or "impl"
129+
VERSION_KEY = "version" # Numeric version number
126130
EXTERNAL_TABLE = "EXTERNAL_TABLE"
127131

128132

@@ -181,8 +185,10 @@ def __init__(self, **properties):
181185
182186
Args:
183187
uri: The Hive Metastore URI (e.g., "thrift://localhost:9083")
188+
root: Storage root location of the lakehouse on Hive catalog (optional)
184189
ugi: User Group Information for authentication (optional, format: "user:group1,group2")
185-
warehouse: The warehouse directory path (optional, defaults to Hive's warehouse)
190+
client.pool-size: Size of the HMS client connection pool (optional, default: 3)
191+
storage.*: Additional storage configurations to access table
186192
**properties: Additional configuration properties
187193
"""
188194
if not HIVE_AVAILABLE:
@@ -193,7 +199,10 @@ def __init__(self, **properties):
193199

194200
self.uri = properties.get("uri", "thrift://localhost:9083")
195201
self.ugi = properties.get("ugi")
196-
self.warehouse = properties.get("warehouse", "/user/hive/warehouse")
202+
self.root = properties.get("root", os.getcwd())
203+
self.pool_size = int(properties.get("client.pool-size", "3"))
204+
# Extract storage properties
205+
self.storage_properties = {k[8:]: v for k, v in properties.items() if k.startswith("storage.")}
197206

198207
# Create client
199208
self._client = HiveMetastoreClient(self.uri, self.ugi)
@@ -207,23 +216,27 @@ def _normalize_identifier(self, identifier: List[str]) -> tuple:
207216
else:
208217
raise ValueError(f"Invalid identifier: {identifier}")
209218

219+
def _is_root_namespace(self, identifier: Optional[List[str]]) -> bool:
220+
"""Check if the identifier refers to the root namespace."""
221+
return not identifier or len(identifier) == 0
222+
210223
def _get_table_location(self, database: str, table: str) -> str:
211224
"""Get the location for a table."""
212-
return os.path.join(self.warehouse, f"{database}.db", table)
225+
return os.path.join(self.root, f"{database}.db", table)
213226

214227
def list_namespaces(self, request: ListNamespacesRequest) -> ListNamespacesResponse:
215228
"""List all databases in the Hive Metastore."""
216229
try:
230+
# Only list namespaces if we're at the root level
231+
if not self._is_root_namespace(request.id):
232+
# Non-root namespaces don't have children in Hive2
233+
return ListNamespacesResponse(namespaces=[])
234+
217235
with self._client as client:
218236
databases = client.get_all_databases()
219-
# Return just database names as strings
237+
# Return just database names as strings (excluding default)
220238
namespaces = [db for db in databases if db != "default"]
221239

222-
# Apply parent filter if specified
223-
if request.id:
224-
# In Hive, databases are flat, so parent filtering doesn't apply
225-
namespaces = []
226-
227240
return ListNamespacesResponse(namespaces=namespaces)
228241
except Exception as e:
229242
logger.error(f"Failed to list namespaces: {e}")
@@ -232,6 +245,16 @@ def list_namespaces(self, request: ListNamespacesRequest) -> ListNamespacesRespo
232245
def describe_namespace(self, request: DescribeNamespaceRequest) -> DescribeNamespaceResponse:
233246
"""Describe a database in the Hive Metastore."""
234247
try:
248+
# Handle root namespace
249+
if self._is_root_namespace(request.id):
250+
properties = {
251+
"location": self.root,
252+
"description": "Root namespace (Hive Metastore)"
253+
}
254+
if self.ugi:
255+
properties["ugi"] = self.ugi
256+
return DescribeNamespaceResponse(properties=properties)
257+
235258
if len(request.id) != 1:
236259
raise ValueError(f"Invalid namespace identifier: {request.id}")
237260

@@ -262,6 +285,10 @@ def describe_namespace(self, request: DescribeNamespaceRequest) -> DescribeNames
262285
def create_namespace(self, request: CreateNamespaceRequest) -> CreateNamespaceResponse:
263286
"""Create a new database in the Hive Metastore."""
264287
try:
288+
# Cannot create root namespace
289+
if self._is_root_namespace(request.id):
290+
raise ValueError("Root namespace already exists")
291+
265292
if len(request.id) != 1:
266293
raise ValueError(f"Invalid namespace identifier: {request.id}")
267294

@@ -276,7 +303,7 @@ def create_namespace(self, request: CreateNamespaceRequest) -> CreateNamespaceRe
276303
database.ownerName = request.properties.get("owner", os.getenv("USER", ""))
277304
database.locationUri = request.properties.get(
278305
"location",
279-
os.path.join(self.warehouse, f"{database_name}.db")
306+
os.path.join(self.root, f"{database_name}.db")
280307
)
281308
database.parameters = {
282309
k: v for k, v in request.properties.items()
@@ -296,6 +323,10 @@ def create_namespace(self, request: CreateNamespaceRequest) -> CreateNamespaceRe
296323
def drop_namespace(self, request: DropNamespaceRequest) -> DropNamespaceResponse:
297324
"""Drop a database from the Hive Metastore."""
298325
try:
326+
# Cannot drop root namespace
327+
if self._is_root_namespace(request.id):
328+
raise ValueError("Cannot drop root namespace")
329+
299330
if len(request.id) != 1:
300331
raise ValueError(f"Invalid namespace identifier: {request.id}")
301332

@@ -321,6 +352,10 @@ def drop_namespace(self, request: DropNamespaceRequest) -> DropNamespaceResponse
321352
def namespace_exists(self, request: NamespaceExistsRequest) -> None:
322353
"""Check if a database exists in the Hive Metastore."""
323354
try:
355+
# Root namespace always exists
356+
if self._is_root_namespace(request.id):
357+
return
358+
324359
if len(request.id) != 1:
325360
raise ValueError(f"Invalid namespace identifier: {request.id}")
326361

@@ -337,6 +372,10 @@ def namespace_exists(self, request: NamespaceExistsRequest) -> None:
337372
def list_tables(self, request: ListTablesRequest) -> ListTablesResponse:
338373
"""List tables in a database."""
339374
try:
375+
# Root namespace has no tables
376+
if self._is_root_namespace(request.id):
377+
return ListTablesResponse(tables=[])
378+
340379
if len(request.id) != 1:
341380
raise ValueError(f"Invalid namespace identifier: {request.id}")
342381

@@ -350,11 +389,12 @@ def list_tables(self, request: ListTablesRequest) -> ListTablesResponse:
350389
for table_name in table_names:
351390
try:
352391
table = client.get_table(database_name, table_name)
353-
# Check if it's a Lance table
354-
if (table.parameters and
355-
table.parameters.get(LANCE_TABLE_TYPE) == LANCE_TABLE_FORMAT):
356-
# Return just table name, not full identifier
357-
tables.append(table_name)
392+
# Check if it's a Lance table (case insensitive)
393+
if table.parameters:
394+
table_type = table.parameters.get(TABLE_TYPE_KEY, "").lower()
395+
if table_type == LANCE_TABLE_FORMAT:
396+
# Return just table name, not full identifier
397+
tables.append(table_name)
358398
except Exception:
359399
# Skip tables we can't read
360400
continue
@@ -374,9 +414,11 @@ def describe_table(self, request: DescribeTableRequest) -> DescribeTableResponse
374414
with self._client as client:
375415
table = client.get_table(database, table_name)
376416

377-
# Check if it's a Lance table
378-
if not (table.parameters and
379-
table.parameters.get(LANCE_TABLE_TYPE) == LANCE_TABLE_FORMAT):
417+
# Check if it's a Lance table (case insensitive)
418+
if not table.parameters:
419+
raise ValueError(f"Table {request.id} is not a Lance table")
420+
table_type = table.parameters.get(TABLE_TYPE_KEY, "").lower()
421+
if table_type != LANCE_TABLE_FORMAT:
380422
raise ValueError(f"Table {request.id} is not a Lance table")
381423

382424
# Get table location
@@ -458,14 +500,18 @@ def register_table(self, request: RegisterTableRequest) -> RegisterTableResponse
458500

459501
hive_table.sd = sd
460502

461-
# Set table parameters
503+
# Set table parameters per hive.md specification
462504
hive_table.parameters = {
463-
LANCE_TABLE_TYPE: LANCE_TABLE_FORMAT,
464-
"lance.version": str(dataset.version),
505+
TABLE_TYPE_KEY: LANCE_TABLE_FORMAT,
506+
MANAGED_BY_KEY: request.properties.get(MANAGED_BY_KEY, "storage"),
507+
VERSION_KEY: str(dataset.version),
465508
"EXTERNAL": "TRUE",
466509
}
467510
if request.properties:
468-
hive_table.parameters.update(request.properties)
511+
# Add other properties but don't override the required ones
512+
for k, v in request.properties.items():
513+
if k not in [TABLE_TYPE_KEY, MANAGED_BY_KEY, VERSION_KEY]:
514+
hive_table.parameters[k] = v
469515

470516
with self._client as client:
471517
client.create_table(hive_table)
@@ -488,9 +534,11 @@ def table_exists(self, request: TableExistsRequest) -> None:
488534
with self._client as client:
489535
table = client.get_table(database, table_name)
490536

491-
# Check if it's a Lance table
492-
if not (table.parameters and
493-
table.parameters.get(LANCE_TABLE_TYPE) == LANCE_TABLE_FORMAT):
537+
# Check if it's a Lance table (case insensitive)
538+
if not table.parameters:
539+
raise ValueError(f"Table {request.id} is not a Lance table")
540+
table_type = table.parameters.get(TABLE_TYPE_KEY, "").lower()
541+
if table_type != LANCE_TABLE_FORMAT:
494542
raise ValueError(f"Table {request.id} is not a Lance table")
495543
except Exception as e:
496544
if NoSuchObjectException and isinstance(e, NoSuchObjectException):
@@ -507,8 +555,11 @@ def drop_table(self, request: DropTableRequest) -> DropTableResponse:
507555
# Get table to check if it's a Lance table
508556
table = client.get_table(database, table_name)
509557

510-
if not (table.parameters and
511-
table.parameters.get(LANCE_TABLE_TYPE) == LANCE_TABLE_FORMAT):
558+
# Check if it's a Lance table (case insensitive)
559+
if not table.parameters:
560+
raise ValueError(f"Table {request.id} is not a Lance table")
561+
table_type = table.parameters.get(TABLE_TYPE_KEY, "").lower()
562+
if table_type != LANCE_TABLE_FORMAT:
512563
raise ValueError(f"Table {request.id} is not a Lance table")
513564

514565
# Drop the table (always delete data for Lance tables)
@@ -530,8 +581,11 @@ def deregister_table(self, request: DeregisterTableRequest) -> DeregisterTableRe
530581
# Get table to check if it's a Lance table
531582
table = client.get_table(database, table_name)
532583

533-
if not (table.parameters and
534-
table.parameters.get(LANCE_TABLE_TYPE) == LANCE_TABLE_FORMAT):
584+
# Check if it's a Lance table (case insensitive)
585+
if not table.parameters:
586+
raise ValueError(f"Table {request.id} is not a Lance table")
587+
table_type = table.parameters.get(TABLE_TYPE_KEY, "").lower()
588+
if table_type != LANCE_TABLE_FORMAT:
535589
raise ValueError(f"Table {request.id} is not a Lance table")
536590

537591
location = table.sd.location if table.sd else None

python/lance_namespace/tests/test_hive.py

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def hive_namespace(mock_hive_client):
4040
with patch("lance_namespace.hive.HIVE_AVAILABLE", True):
4141
namespace = connect("hive2", {
4242
"uri": "thrift://localhost:9083",
43-
"warehouse": "/tmp/warehouse"
43+
"root": "/tmp/warehouse"
4444
})
4545
namespace._client = mock_hive_client
4646
return namespace
@@ -55,12 +55,12 @@ def test_initialization(self):
5555
with patch("lance_namespace.hive.HiveMetastoreClient") as mock_client:
5656
namespace = connect("hive2", {
5757
"uri": "thrift://localhost:9083",
58-
"warehouse": "/tmp/warehouse",
58+
"root": "/tmp/warehouse",
5959
"ugi": "user:group1,group2"
6060
})
6161

6262
assert namespace.uri == "thrift://localhost:9083"
63-
assert namespace.warehouse == "/tmp/warehouse"
63+
assert namespace.root == "/tmp/warehouse"
6464
assert namespace.ugi == "user:group1,group2"
6565
mock_client.assert_called_once_with("thrift://localhost:9083", "user:group1,group2")
6666

@@ -173,13 +173,13 @@ def test_namespace_exists(self, hive_namespace, mock_hive_client):
173173
def test_list_tables(self, hive_namespace, mock_hive_client):
174174
"""Test listing tables in a namespace."""
175175
mock_table1 = MagicMock()
176-
mock_table1.parameters = {"lance.table_type": "LANCE"}
176+
mock_table1.parameters = {"table_type": "lance"}
177177

178178
mock_table2 = MagicMock()
179179
mock_table2.parameters = {"other_type": "OTHER"}
180180

181181
mock_table3 = MagicMock()
182-
mock_table3.parameters = {"lance.table_type": "LANCE"}
182+
mock_table3.parameters = {"table_type": "lance"}
183183

184184
mock_client_instance = MagicMock()
185185
mock_client_instance.get_all_tables.return_value = ["table1", "table2", "table3"]
@@ -245,13 +245,13 @@ def test_register_table(self, hive_namespace, mock_hive_client):
245245
assert mock_hive_table.tableName == "test_table"
246246
assert mock_hive_table.tableType == "EXTERNAL_TABLE"
247247
assert mock_sd.location == table_path
248-
assert mock_hive_table.parameters["lance.table_type"] == "LANCE"
248+
assert mock_hive_table.parameters["table_type"] == "lance"
249249
assert mock_hive_table.parameters["owner"] == "test_user"
250250

251251
def test_table_exists(self, hive_namespace, mock_hive_client):
252252
"""Test checking if a table exists."""
253253
mock_table = MagicMock()
254-
mock_table.parameters = {"lance.table_type": "LANCE"}
254+
mock_table.parameters = {"table_type": "lance"}
255255

256256
mock_client_instance = MagicMock()
257257
mock_client_instance.get_table.return_value = mock_table
@@ -265,7 +265,7 @@ def test_table_exists(self, hive_namespace, mock_hive_client):
265265
def test_drop_table(self, hive_namespace, mock_hive_client):
266266
"""Test dropping a table."""
267267
mock_table = MagicMock()
268-
mock_table.parameters = {"lance.table_type": "LANCE"}
268+
mock_table.parameters = {"table_type": "lance"}
269269

270270
mock_client_instance = MagicMock()
271271
mock_client_instance.get_table.return_value = mock_table
@@ -282,7 +282,7 @@ def test_drop_table(self, hive_namespace, mock_hive_client):
282282
def test_deregister_table(self, hive_namespace, mock_hive_client):
283283
"""Test deregistering a table without deleting data."""
284284
mock_table = MagicMock()
285-
mock_table.parameters = {"lance.table_type": "LANCE"}
285+
mock_table.parameters = {"table_type": "lance"}
286286
mock_table.sd.location = "/tmp/test_table"
287287

288288
mock_client_instance = MagicMock()
@@ -312,4 +312,31 @@ def test_normalize_identifier(self, hive_namespace):
312312
def test_get_table_location(self, hive_namespace):
313313
"""Test getting table location."""
314314
location = hive_namespace._get_table_location("test_db", "test_table")
315-
assert location == "/tmp/warehouse/test_db.db/test_table"
315+
assert location == "/tmp/warehouse/test_db.db/test_table"
316+
317+
def test_root_namespace_operations(self, hive_namespace):
318+
"""Test root namespace operations."""
319+
# Test namespace_exists for root
320+
request = NamespaceExistsRequest(id=[])
321+
hive_namespace.namespace_exists(request) # Should not raise
322+
323+
# Test describe_namespace for root
324+
request = DescribeNamespaceRequest(id=[])
325+
response = hive_namespace.describe_namespace(request)
326+
assert response.properties["location"] == "/tmp/warehouse"
327+
assert "Root namespace" in response.properties["description"]
328+
329+
# Test list_tables for root (should be empty)
330+
request = ListTablesRequest(id=[])
331+
response = hive_namespace.list_tables(request)
332+
assert response.tables == []
333+
334+
# Test create_namespace for root (should fail)
335+
request = CreateNamespaceRequest(id=[])
336+
with pytest.raises(ValueError, match="Root namespace already exists"):
337+
hive_namespace.create_namespace(request)
338+
339+
# Test drop_namespace for root (should fail)
340+
request = DropNamespaceRequest(id=[])
341+
with pytest.raises(ValueError, match="Cannot drop root namespace"):
342+
hive_namespace.drop_namespace(request)

0 commit comments

Comments
 (0)