This is an automated email from the ASF dual-hosted git repository.
jerryshao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new 1ead02d06d [#10808] Improvement(client-python): Add Owner management
API (get_owner / set_owner) (#10809)
1ead02d06d is described below
commit 1ead02d06d9db2633e6fbcff51a07bdea111c791
Author: Sun Yuhan <[email protected]>
AuthorDate: Mon May 11 14:25:28 2026 +0800
[#10808] Improvement(client-python): Add Owner management API (get_owner /
set_owner) (#10809)
### What changes were proposed in this pull request?
Add `get_owner` / `set_owner` API to the Python SDK, aligning with the
Java client's `GravitinoMetalake.getOwner()` / `setOwner()` methods.
**New files:**
- `gravitino/api/authorization/owner.py` — Owner interface with `Type`
enum (`USER` / `GROUP`)
- `gravitino/dto/authorization/owner_dto.py` — OwnerDTO
- `gravitino/dto/requests/owner_set_request.py` — setOwner request body
- `gravitino/dto/responses/owner_response.py` — getOwner response
- `gravitino/dto/responses/set_response.py` — setOwner response
- `gravitino/exceptions/handlers/owner_error_handler.py` — Owner error
handler
**Modified files:**
- `gravitino/client/gravitino_metalake.py` — Add `get_owner` /
`set_owner` methods
- `gravitino/client/gravitino_client.py` — Add delegation methods
### Why are the changes needed?
The Java SDK already supports ownership management, but the Python SDK
lacks these APIs. Users must fall back to raw REST calls via `requests`,
which is inconsistent with the SDK design.
Fix: #10808
### Does this PR introduce _any_ user-facing change?
Yes, two new public APIs are added:
- `GravitinoClient.get_owner(metadata_object)` → `Optional[Owner]`
- `GravitinoClient.set_owner(metadata_object, owner_name, owner_type)` →
`None`
No existing APIs are changed.
### How was this patch tested?
Unit tests added in `tests/unittests/test_owner.py` (17 test cases):
- Integration tests: `get_owner` (user/group/none), `set_owner`
(success/false)
- Error handler tests: all 5 error code branches (`ILLEGAL_ARGUMENTS`,
`NOT_FOUND`, `UNSUPPORTED_OPERATION`, `NOT_IN_USE`, `INTERNAL_ERROR`)
- Validation tests: `OwnerSetRequest.validate()` (empty name, none
type), `OwnerResponse.validate()` (empty name, none type, no owner)
---------
Co-authored-by: Sun Yuhan <[email protected]>
Co-authored-by: Jerry Shao <[email protected]>
---
.../gravitino/api/authorization/owner.py | 45 +++
.../gravitino/client/gravitino_client.py | 37 +++
.../gravitino/client/gravitino_metalake.py | 63 +++-
.../gravitino/dto/authorization/__init__.py | 18 ++
.../gravitino/dto/authorization/owner_dto.py | 37 +++
.../gravitino/dto/requests/owner_set_request.py | 45 +++
.../gravitino/dto/responses/owner_response.py | 47 +++
.../gravitino/dto/responses/set_response.py | 32 ++
.../exceptions/handlers/owner_error_handler.py | 58 ++++
.../client-python/tests/unittests/test_owner.py | 349 +++++++++++++++++++++
10 files changed, 730 insertions(+), 1 deletion(-)
diff --git a/clients/client-python/gravitino/api/authorization/owner.py
b/clients/client-python/gravitino/api/authorization/owner.py
new file mode 100644
index 0000000000..63d2b469d1
--- /dev/null
+++ b/clients/client-python/gravitino/api/authorization/owner.py
@@ -0,0 +1,45 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from abc import ABC, abstractmethod
+from enum import Enum
+
+
+class Owner(ABC):
+ """The interface of an owner. The owner represents the user or group who
owns a metadata object."""
+
+ class Type(Enum):
+ """The type of the owner."""
+
+ USER = "USER"
+ GROUP = "GROUP"
+
+ @abstractmethod
+ def name(self) -> str:
+ """The name of the owner.
+
+ Returns:
+ str: The name of the owner.
+ """
+
+ @abstractmethod
+ def type(self) -> "Owner.Type":
+ """The type of the owner.
+
+ Returns:
+ Owner.Type: The type of the owner.
+ """
diff --git a/clients/client-python/gravitino/client/gravitino_client.py
b/clients/client-python/gravitino/client/gravitino_client.py
index 29c36a8937..c1cd8e5b27 100644
--- a/clients/client-python/gravitino/client/gravitino_client.py
+++ b/clients/client-python/gravitino/client/gravitino_client.py
@@ -19,12 +19,14 @@ from __future__ import annotations
from typing import Dict, List, Optional
+from gravitino.api.authorization.owner import Owner
from gravitino.api.catalog import Catalog
from gravitino.api.catalog_change import CatalogChange
from gravitino.api.job.job_handle import JobHandle
from gravitino.api.job.job_template import JobTemplate
from gravitino.api.job.job_template_change import JobTemplateChange
from gravitino.api.job.supports_jobs import SupportsJobs
+from gravitino.api.metadata_object import MetadataObject
from gravitino.api.tag.tag_operations import TagOperations
from gravitino.auth.auth_data_provider import AuthDataProvider
from gravitino.client.gravitino_client_base import GravitinoClientBase
@@ -332,3 +334,38 @@ class GravitinoClient(GravitinoClientBase, SupportsJobs,
TagOperations):
NoSuchMetalakeException: If the metalake does not exist.
"""
return self.get_metalake().delete_tag(tag_name)
+
+ # Owner operations
+ def get_owner(self, metadata_object: MetadataObject) -> Optional[Owner]:
+ """Get the owner of a metadata object.
+
+ Args:
+ metadata_object: The metadata object to get the owner for.
+
+ Returns:
+ Optional[Owner]: The owner of the metadata object, or None if no
owner is set.
+
+ Raises:
+ NoSuchMetadataObjectException: If the metadata object does not
exist.
+ NotFoundException: If a related resource is not found.
+ MetalakeNotInUseException: If the metalake is not in use.
+ """
+ return self.get_metalake().get_owner(metadata_object)
+
+ def set_owner(
+ self, metadata_object: MetadataObject, owner_name: str, owner_type:
Owner.Type
+ ) -> None:
+ """Set the owner of a metadata object.
+
+ Args:
+ metadata_object: The metadata object to set the owner for.
+ owner_name: The name of the owner.
+ owner_type: The type of the owner (USER or GROUP).
+
+ Raises:
+ NoSuchMetadataObjectException: If the metadata object does not
exist.
+ NotFoundException: If a related resource is not found.
+ MetalakeNotInUseException: If the metalake is not in use.
+ UnsupportedOperationException: If the operation is not supported.
+ """
+ self.get_metalake().set_owner(metadata_object, owner_name, owner_type)
diff --git a/clients/client-python/gravitino/client/gravitino_metalake.py
b/clients/client-python/gravitino/client/gravitino_metalake.py
index 3bd3d2fff2..9d865b20fb 100644
--- a/clients/client-python/gravitino/client/gravitino_metalake.py
+++ b/clients/client-python/gravitino/client/gravitino_metalake.py
@@ -16,14 +16,16 @@
# under the License.
import logging
-from typing import Dict, List
+from typing import Dict, List, Optional
+from gravitino.api.authorization.owner import Owner
from gravitino.api.catalog import Catalog
from gravitino.api.catalog_change import CatalogChange
from gravitino.api.job.job_handle import JobHandle
from gravitino.api.job.job_template import JobTemplate
from gravitino.api.job.job_template_change import JobTemplateChange
from gravitino.api.job.supports_jobs import SupportsJobs
+from gravitino.api.metadata_object import MetadataObject
from gravitino.api.tag.tag import Tag
from gravitino.api.tag.tag_operations import TagOperations
from gravitino.client.dto_converters import DTOConverters
@@ -40,6 +42,7 @@ from gravitino.dto.requests.job_template_register_request
import (
from gravitino.dto.requests.job_template_updates_request import (
JobTemplateUpdatesRequest,
)
+from gravitino.dto.requests.owner_set_request import OwnerSetRequest
from gravitino.dto.requests.tag_create_request import TagCreateRequest
from gravitino.dto.requests.tag_updates_request import TagUpdatesRequest
from gravitino.dto.responses.catalog_list_response import CatalogListResponse
@@ -50,6 +53,8 @@ from gravitino.dto.responses.job_list_response import
JobListResponse
from gravitino.dto.responses.job_response import JobResponse
from gravitino.dto.responses.job_template_list_response import
JobTemplateListResponse
from gravitino.dto.responses.job_template_response import JobTemplateResponse
+from gravitino.dto.responses.owner_response import OwnerResponse
+from gravitino.dto.responses.set_response import SetResponse
from gravitino.dto.responses.tag_response import (
TagListResponse,
TagNamesListResponse,
@@ -57,6 +62,7 @@ from gravitino.dto.responses.tag_response import (
)
from gravitino.exceptions.handlers.catalog_error_handler import
CATALOG_ERROR_HANDLER
from gravitino.exceptions.handlers.job_error_handler import JOB_ERROR_HANDLER
+from gravitino.exceptions.handlers.owner_error_handler import
OWNER_ERROR_HANDLER
from gravitino.exceptions.handlers.tag_error_handler import TAG_ERROR_HANDLER
from gravitino.rest.rest_utils import encode_string
from gravitino.utils.http_client import HTTPClient
@@ -82,6 +88,7 @@ class GravitinoMetalake(
API_METALAKES_CATALOGS_PATH = "api/metalakes/{}/catalogs/{}"
API_METALAKES_JOB_TEMPLATES_PATH = "api/metalakes/{}/jobs/templates"
API_METALAKES_JOB_RUNS_PATH = "api/metalakes/{}/jobs/runs"
+ API_METALAKES_OWNERS_PATH = "api/metalakes/{}/owners/{}"
API_METALAKES_TAG_PATH = "api/metalakes/{}/tags/{}"
API_METALAKES_TAGS_PATH = "api/metalakes/{}/tags"
@@ -706,3 +713,57 @@ class GravitinoMetalake(
drop_response.validate()
return drop_response.dropped()
+
+ #########
+ # Owner operations
+ #########
+ def get_owner(self, metadata_object: MetadataObject) -> Optional[Owner]:
+ """Get the owner of a metadata object.
+
+ Args:
+ metadata_object: The metadata object to get the owner for.
+
+ Returns:
+ Optional[Owner]: The owner of the metadata object, or None if no
owner is set.
+
+ Raises:
+ NoSuchMetadataObjectException: If the metadata object does not
exist.
+ NotFoundException: If a related resource is not found.
+ MetalakeNotInUseException: If the metalake is not in use.
+ """
+ url = self.API_METALAKES_OWNERS_PATH.format(
+ encode_string(self.name()),
+
f"{metadata_object.type().value}/{encode_string(metadata_object.full_name())}",
+ )
+ response = self.rest_client.get(url, error_handler=OWNER_ERROR_HANDLER)
+ resp = OwnerResponse.from_json(response.body, infer_missing=True)
+ resp.validate()
+ return resp.owner()
+
+ def set_owner(
+ self, metadata_object: MetadataObject, owner_name: str, owner_type:
Owner.Type
+ ) -> None:
+ """Set the owner of a metadata object.
+
+ Args:
+ metadata_object: The metadata object to set the owner for.
+ owner_name: The name of the owner.
+ owner_type: The type of the owner (USER or GROUP).
+
+ Raises:
+ NoSuchMetadataObjectException: If the metadata object does not
exist.
+ NotFoundException: If a related resource is not found.
+ MetalakeNotInUseException: If the metalake is not in use.
+ UnsupportedOperationException: If the operation is not supported.
+ """
+ url = self.API_METALAKES_OWNERS_PATH.format(
+ encode_string(self.name()),
+
f"{metadata_object.type().value}/{encode_string(metadata_object.full_name())}",
+ )
+ req = OwnerSetRequest(owner_name, owner_type)
+ req.validate()
+ response = self.rest_client.put(
+ url, json=req, error_handler=OWNER_ERROR_HANDLER
+ )
+ set_resp = SetResponse.from_json(response.body, infer_missing=True)
+ set_resp.validate()
diff --git a/clients/client-python/gravitino/dto/authorization/__init__.py
b/clients/client-python/gravitino/dto/authorization/__init__.py
new file mode 100644
index 0000000000..26a4e378cf
--- /dev/null
+++ b/clients/client-python/gravitino/dto/authorization/__init__.py
@@ -0,0 +1,18 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from gravitino.dto.authorization.owner_dto import OwnerDTO
diff --git a/clients/client-python/gravitino/dto/authorization/owner_dto.py
b/clients/client-python/gravitino/dto/authorization/owner_dto.py
new file mode 100644
index 0000000000..a68cd6abb1
--- /dev/null
+++ b/clients/client-python/gravitino/dto/authorization/owner_dto.py
@@ -0,0 +1,37 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from dataclasses import dataclass, field
+
+from dataclasses_json import config, dataclass_json
+
+from gravitino.api.authorization.owner import Owner
+
+
+@dataclass_json
+@dataclass
+class OwnerDTO(Owner):
+ """Represents an Owner Data Transfer Object (DTO)."""
+
+ _name: str = field(metadata=config(field_name="name"))
+ _type: Owner.Type = field(metadata=config(field_name="type"))
+
+ def name(self) -> str:
+ return self._name
+
+ def type(self) -> Owner.Type:
+ return self._type
diff --git a/clients/client-python/gravitino/dto/requests/owner_set_request.py
b/clients/client-python/gravitino/dto/requests/owner_set_request.py
new file mode 100644
index 0000000000..488cfc0f8b
--- /dev/null
+++ b/clients/client-python/gravitino/dto/requests/owner_set_request.py
@@ -0,0 +1,45 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from dataclasses import dataclass, field
+
+from dataclasses_json import config, dataclass_json
+
+from gravitino.api.authorization.owner import Owner
+from gravitino.exceptions.base import IllegalArgumentException
+from gravitino.rest.rest_message import RESTRequest
+from gravitino.utils.precondition import Precondition
+
+
+@dataclass_json
+@dataclass
+class OwnerSetRequest(RESTRequest):
+ """Represents a request to set an owner for a metadata object."""
+
+ _name: str = field(metadata=config(field_name="name"))
+ _type: Owner.Type = field(metadata=config(field_name="type"))
+
+ def __init__(self, name: str, owner_type: Owner.Type):
+ self._name = name
+ self._type = owner_type
+
+ def validate(self) -> None:
+ Precondition.check_string_not_empty(
+ self._name, '"name" field is required'
+ )
+ if self._type is None:
+ raise IllegalArgumentException('"type" field is required')
diff --git a/clients/client-python/gravitino/dto/responses/owner_response.py
b/clients/client-python/gravitino/dto/responses/owner_response.py
new file mode 100644
index 0000000000..e07209fb0e
--- /dev/null
+++ b/clients/client-python/gravitino/dto/responses/owner_response.py
@@ -0,0 +1,47 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from dataclasses import dataclass, field
+from typing import Optional
+
+from dataclasses_json import config
+
+from gravitino.dto.authorization.owner_dto import OwnerDTO
+from gravitino.dto.responses.base_response import BaseResponse
+from gravitino.exceptions.base import IllegalArgumentException
+from gravitino.utils.precondition import Precondition
+
+
+@dataclass
+class OwnerResponse(BaseResponse):
+ """Represents a response for the get owner operation."""
+
+ _owner: Optional[OwnerDTO] = field(
+ default=None, metadata=config(field_name="owner")
+ )
+
+ def owner(self) -> Optional[OwnerDTO]:
+ return self._owner
+
+ def validate(self) -> None:
+ super().validate()
+ if self._owner is not None:
+ Precondition.check_string_not_empty(
+ self._owner.name(), "owner name must not be empty"
+ )
+ if self._owner.type() is None:
+ raise IllegalArgumentException("owner type must not be None")
diff --git a/clients/client-python/gravitino/dto/responses/set_response.py
b/clients/client-python/gravitino/dto/responses/set_response.py
new file mode 100644
index 0000000000..23a9b8ebdb
--- /dev/null
+++ b/clients/client-python/gravitino/dto/responses/set_response.py
@@ -0,0 +1,32 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from dataclasses import dataclass, field
+
+from dataclasses_json import config
+
+from gravitino.dto.responses.base_response import BaseResponse
+
+
+@dataclass
+class SetResponse(BaseResponse):
+ """Represents a response for a set operation."""
+
+ _set: bool = field(metadata=config(field_name="set"))
+
+ def set(self) -> bool:
+ return self._set
diff --git
a/clients/client-python/gravitino/exceptions/handlers/owner_error_handler.py
b/clients/client-python/gravitino/exceptions/handlers/owner_error_handler.py
new file mode 100644
index 0000000000..bda819cd82
--- /dev/null
+++ b/clients/client-python/gravitino/exceptions/handlers/owner_error_handler.py
@@ -0,0 +1,58 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from gravitino.constants.error import ErrorConstants
+from gravitino.exceptions.base import (
+ IllegalArgumentException,
+ MetalakeNotInUseException,
+ NoSuchMetadataObjectException,
+ NotFoundException,
+ UnsupportedOperationException,
+)
+from gravitino.exceptions.handlers.rest_error_handler import RestErrorHandler
+
+
+class OwnerErrorHandler(RestErrorHandler):
+ """Error handler specific to Owner operations."""
+
+ def handle(self, error_response) -> None:
+ error_message = error_response.format_error_message()
+ code = error_response.code()
+ exception_type = error_response.type()
+
+ if code == ErrorConstants.ILLEGAL_ARGUMENTS_CODE:
+ raise IllegalArgumentException(error_message)
+
+ if code == ErrorConstants.NOT_FOUND_CODE:
+ if exception_type == NoSuchMetadataObjectException.__name__:
+ raise NoSuchMetadataObjectException(error_message)
+
+ raise NotFoundException(error_message)
+
+ if code == ErrorConstants.UNSUPPORTED_OPERATION_CODE:
+ raise UnsupportedOperationException(error_message)
+
+ if code == ErrorConstants.NOT_IN_USE_CODE:
+ raise MetalakeNotInUseException(error_message)
+
+ if code == ErrorConstants.INTERNAL_ERROR_CODE:
+ raise RuntimeError(error_message)
+
+ super().handle(error_response)
+
+
+OWNER_ERROR_HANDLER = OwnerErrorHandler()
diff --git a/clients/client-python/tests/unittests/test_owner.py
b/clients/client-python/tests/unittests/test_owner.py
new file mode 100644
index 0000000000..2da3b78487
--- /dev/null
+++ b/clients/client-python/tests/unittests/test_owner.py
@@ -0,0 +1,349 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import json as _json
+import unittest
+from unittest.mock import patch
+
+from gravitino import GravitinoClient
+from gravitino.api.authorization.owner import Owner
+from gravitino.api.metadata_object import MetadataObject
+from gravitino.dto.authorization.owner_dto import OwnerDTO
+from gravitino.dto.metadata_object_dto import MetadataObjectDTO
+from gravitino.dto.requests.owner_set_request import OwnerSetRequest
+from gravitino.dto.responses.error_response import ErrorResponse
+from gravitino.dto.responses.owner_response import OwnerResponse
+from gravitino.dto.responses.set_response import SetResponse
+from gravitino.exceptions.base import (
+ IllegalArgumentException,
+ MetalakeNotInUseException,
+ NoSuchMetadataObjectException,
+ NotFoundException,
+ UnsupportedOperationException,
+)
+from gravitino.exceptions.handlers.owner_error_handler import
OWNER_ERROR_HANDLER
+from tests.unittests import mock_base
+
+
+@mock_base.mock_data
+class TestOwner(unittest.TestCase):
+ _metalake_name: str = "metalake_demo"
+
+ def test_get_owner(self, *mock_method) -> None:
+ owner_resp = OwnerResponse(0, {"name": "alice", "type": "USER"})
+ json_str = owner_resp.to_json()
+ mock_resp = mock_base.mock_http_response(json_str)
+ client = GravitinoClient(
+ uri="http://localhost:8090",
+ metalake_name=self._metalake_name,
+ check_version=False,
+ )
+
+ metadata_object = (
+ MetadataObjectDTO.builder()
+ .type(MetadataObject.Type.CATALOG)
+ .full_name("test_catalog")
+ .build()
+ )
+
+ with patch(
+ "gravitino.utils.http_client.HTTPClient.get",
+ return_value=mock_resp,
+ ):
+ owner = client.get_owner(metadata_object)
+ self.assertIsNotNone(owner)
+ self.assertEqual("alice", owner.name())
+ self.assertEqual(Owner.Type.USER, owner.type())
+
+ def test_get_owner_none(self, *mock_method) -> None:
+ owner_resp = OwnerResponse(0, None)
+ json_str = owner_resp.to_json()
+ mock_resp = mock_base.mock_http_response(json_str)
+ client = GravitinoClient(
+ uri="http://localhost:8090",
+ metalake_name=self._metalake_name,
+ check_version=False,
+ )
+
+ metadata_object = (
+ MetadataObjectDTO.builder()
+ .type(MetadataObject.Type.CATALOG)
+ .full_name("test_catalog")
+ .build()
+ )
+
+ with patch(
+ "gravitino.utils.http_client.HTTPClient.get",
+ return_value=mock_resp,
+ ):
+ owner = client.get_owner(metadata_object)
+ self.assertIsNone(owner)
+
+ def test_set_owner(self, *mock_method) -> None:
+ set_resp = SetResponse(0, True)
+ json_str = set_resp.to_json()
+ mock_resp = mock_base.mock_http_response(json_str)
+ client = GravitinoClient(
+ uri="http://localhost:8090",
+ metalake_name=self._metalake_name,
+ check_version=False,
+ )
+
+ metadata_object = (
+ MetadataObjectDTO.builder()
+ .type(MetadataObject.Type.CATALOG)
+ .full_name("test_catalog")
+ .build()
+ )
+
+ with patch(
+ "gravitino.utils.http_client.HTTPClient.put",
+ return_value=mock_resp,
+ ) as mock_put:
+ client.set_owner(metadata_object, "alice", Owner.Type.USER)
+ mock_put.assert_called_once()
+
+ def test_set_owner_returns_false(self, *mock_method) -> None:
+ set_resp = SetResponse(0, False)
+ json_str = set_resp.to_json()
+ mock_resp = mock_base.mock_http_response(json_str)
+ client = GravitinoClient(
+ uri="http://localhost:8090",
+ metalake_name=self._metalake_name,
+ check_version=False,
+ )
+
+ metadata_object = (
+ MetadataObjectDTO.builder()
+ .type(MetadataObject.Type.CATALOG)
+ .full_name("test_catalog")
+ .build()
+ )
+
+ with patch(
+ "gravitino.utils.http_client.HTTPClient.put",
+ return_value=mock_resp,
+ ) as mock_put:
+ client.set_owner(metadata_object, "alice", Owner.Type.USER)
+ mock_put.assert_called_once()
+
+ def test_get_owner_with_group(self, *mock_method) -> None:
+ owner_resp = OwnerResponse(0, {"name": "admin_group", "type": "GROUP"})
+ json_str = owner_resp.to_json()
+ mock_resp = mock_base.mock_http_response(json_str)
+ client = GravitinoClient(
+ uri="http://localhost:8090",
+ metalake_name=self._metalake_name,
+ check_version=False,
+ )
+
+ metadata_object = (
+ MetadataObjectDTO.builder()
+ .type(MetadataObject.Type.SCHEMA)
+ .full_name("test_catalog.test_schema")
+ .build()
+ )
+
+ with patch(
+ "gravitino.utils.http_client.HTTPClient.get",
+ return_value=mock_resp,
+ ):
+ owner = client.get_owner(metadata_object)
+ self.assertIsNotNone(owner)
+ self.assertEqual("admin_group", owner.name())
+ self.assertEqual(Owner.Type.GROUP, owner.type())
+
+
+class TestOwnerErrorHandler(unittest.TestCase):
+ def test_illegal_arguments(self):
+ with self.assertRaises(IllegalArgumentException):
+ OWNER_ERROR_HANDLER.handle(
+ ErrorResponse.generate_error_response(
+ IllegalArgumentException, "mock error"
+ )
+ )
+
+ def test_not_found_metadata_object(self):
+ with self.assertRaises(NoSuchMetadataObjectException):
+ OWNER_ERROR_HANDLER.handle(
+ ErrorResponse.generate_error_response(
+ NoSuchMetadataObjectException, "mock error"
+ )
+ )
+
+ def test_not_found_generic(self):
+ with self.assertRaises(NotFoundException):
+ OWNER_ERROR_HANDLER.handle(
+ ErrorResponse.generate_error_response(NotFoundException, "mock
error")
+ )
+
+ def test_unsupported_operation(self):
+ with self.assertRaises(UnsupportedOperationException):
+ OWNER_ERROR_HANDLER.handle(
+ ErrorResponse.generate_error_response(
+ UnsupportedOperationException, "mock error"
+ )
+ )
+
+ def test_not_in_use(self):
+ with self.assertRaises(MetalakeNotInUseException):
+ OWNER_ERROR_HANDLER.handle(
+ ErrorResponse.generate_error_response(
+ MetalakeNotInUseException, "mock error"
+ )
+ )
+
+ def test_internal_error(self):
+ with self.assertRaises(RuntimeError):
+ OWNER_ERROR_HANDLER.handle(
+ ErrorResponse.generate_error_response(RuntimeError, "mock
error")
+ )
+
+
+class TestOwnerSetRequestValidation(unittest.TestCase):
+ # pylint: disable=protected-access
+ def test_validate_empty_name(self):
+ req = OwnerSetRequest.__new__(OwnerSetRequest)
+ req._name = ""
+ req._type = Owner.Type.USER
+ with self.assertRaises(ValueError):
+ req.validate()
+
+ def test_validate_none_type(self):
+ req = OwnerSetRequest.__new__(OwnerSetRequest)
+ req._name = "alice"
+ req._type = None
+ with self.assertRaises(ValueError):
+ req.validate()
+
+ def test_validate_success(self):
+ req = OwnerSetRequest("alice", Owner.Type.USER)
+ req.validate()
+
+ def test_validate_whitespace_name(self):
+ req = OwnerSetRequest.__new__(OwnerSetRequest)
+ req._name = " "
+ req._type = Owner.Type.USER
+ with self.assertRaises(ValueError):
+ req.validate()
+
+
+class TestOwnerResponseValidation(unittest.TestCase):
+ def test_validate_owner_with_empty_name(self):
+ owner_dto = OwnerDTO(_name="", _type=Owner.Type.USER)
+ resp = OwnerResponse(0, owner_dto)
+ with self.assertRaises(ValueError):
+ resp.validate()
+
+ def test_validate_owner_with_whitespace_name(self):
+ owner_dto = OwnerDTO(_name=" ", _type=Owner.Type.USER)
+ resp = OwnerResponse(0, owner_dto)
+ with self.assertRaises(ValueError):
+ resp.validate()
+
+ def test_validate_owner_with_none_type(self):
+ owner_dto = OwnerDTO(_name="alice", _type=None)
+ resp = OwnerResponse(0, owner_dto)
+ with self.assertRaises(ValueError):
+ resp.validate()
+
+ def test_validate_no_owner(self):
+ resp = OwnerResponse(0, None)
+ resp.validate()
+
+
+class TestOwnerDTOSerialization(unittest.TestCase):
+ def test_owner_dto_serialize(self):
+ owner = OwnerDTO(_name="alice", _type=Owner.Type.USER)
+ expected = _json.dumps({"name": "alice", "type": "USER"})
+ self.assertEqual(expected, owner.to_json())
+
+ def test_owner_dto_deserialize(self):
+ json_str = _json.dumps({"name": "bob", "type": "GROUP"})
+ owner = OwnerDTO.from_json(json_str)
+ self.assertEqual("bob", owner.name())
+ self.assertEqual(Owner.Type.GROUP, owner.type())
+
+ def test_owner_dto_round_trip(self):
+ original = OwnerDTO(_name="alice", _type=Owner.Type.USER)
+ json_str = original.to_json()
+ restored = OwnerDTO.from_json(json_str)
+ self.assertEqual(original.name(), restored.name())
+ self.assertEqual(original.type(), restored.type())
+
+
+class TestOwnerSetRequestSerialization(unittest.TestCase):
+ def test_owner_set_request_serialize(self):
+ req = OwnerSetRequest("alice", Owner.Type.USER)
+ expected = _json.dumps({"name": "alice", "type": "USER"})
+ self.assertEqual(expected, req.to_json())
+
+ def test_owner_set_request_serialize_group(self):
+ req = OwnerSetRequest("admin_group", Owner.Type.GROUP)
+ expected = _json.dumps({"name": "admin_group", "type": "GROUP"})
+ self.assertEqual(expected, req.to_json())
+
+ def test_owner_set_request_to_dict(self):
+ req = OwnerSetRequest("alice", Owner.Type.USER)
+ req_dict = req.to_dict()
+ self.assertEqual("alice", req_dict["name"])
+ self.assertEqual(Owner.Type.USER, req_dict["type"])
+
+ def test_owner_set_request_to_dict_group(self):
+ req = OwnerSetRequest("admin_group", Owner.Type.GROUP)
+ req_dict = req.to_dict()
+ self.assertEqual("admin_group", req_dict["name"])
+ self.assertEqual(Owner.Type.GROUP, req_dict["type"])
+
+
+class TestOwnerResponseSerialization(unittest.TestCase):
+ def test_owner_response_deserialize_with_user(self):
+ json_str = _json.dumps({"code": 0, "owner": {"name": "alice", "type":
"USER"}})
+ resp = OwnerResponse.from_json(json_str)
+ resp.validate()
+ self.assertEqual(0, resp.code())
+ self.assertIsNotNone(resp.owner())
+ self.assertEqual("alice", resp.owner().name())
+ self.assertEqual(Owner.Type.USER, resp.owner().type())
+
+ def test_owner_response_deserialize_with_group(self):
+ json_str = _json.dumps(
+ {"code": 0, "owner": {"name": "admin_group", "type": "GROUP"}}
+ )
+ resp = OwnerResponse.from_json(json_str)
+ resp.validate()
+ self.assertIsNotNone(resp.owner())
+ self.assertEqual("admin_group", resp.owner().name())
+ self.assertEqual(Owner.Type.GROUP, resp.owner().type())
+
+ def test_owner_response_deserialize_no_owner(self):
+ json_str = _json.dumps({"code": 0, "owner": None})
+ resp = OwnerResponse.from_json(json_str)
+ resp.validate()
+ self.assertIsNone(resp.owner())
+
+ def test_owner_response_round_trip(self):
+ original = OwnerResponse(0, OwnerDTO(_name="alice",
_type=Owner.Type.USER))
+ original.validate()
+ json_str = original.to_json()
+ restored = OwnerResponse.from_json(json_str)
+ restored.validate()
+ self.assertEqual(original.code(), restored.code())
+ self.assertEqual(original.owner().name(), restored.owner().name())
+ self.assertEqual(original.owner().type(), restored.owner().type())