diff --git a/clients/client-python/.gitignore b/clients/client-python/.gitignore index 3d84a1cad05..0970fa3bf1e 100644 --- a/clients/client-python/.gitignore +++ b/clients/client-python/.gitignore @@ -12,6 +12,7 @@ venv dist build README.md +version.ini # Unit test / coverage reports htmlcov/ diff --git a/clients/client-python/MANIFEST.in b/clients/client-python/MANIFEST.in index edc31de3fbe..b2e458eb4fd 100644 --- a/clients/client-python/MANIFEST.in +++ b/clients/client-python/MANIFEST.in @@ -3,4 +3,5 @@ include requirements.txt include requirements-dev.txt -include README.md \ No newline at end of file +include README.md +include version.ini \ No newline at end of file diff --git a/clients/client-python/build.gradle.kts b/clients/client-python/build.gradle.kts index 294dca9094c..e5fdd185303 100644 --- a/clients/client-python/build.gradle.kts +++ b/clients/client-python/build.gradle.kts @@ -100,14 +100,14 @@ tasks { val black by registering(VenvTask::class) { dependsOn(pipInstall) venvExec = "black" - args = listOf("./gravitino", "./tests") + args = listOf("./gravitino", "./tests", "./scripts") } val pylint by registering(VenvTask::class) { dependsOn(pipInstall) mustRunAfter(black) venvExec = "pylint" - args = listOf("./gravitino", "./tests") + args = listOf("./gravitino", "./tests", "./scripts") } val integrationCoverageReport by registering(VenvTask::class){ @@ -162,16 +162,20 @@ tasks { } val build by registering(VenvTask::class) { + dependsOn(pylint) + venvExec = "python" + args = listOf("scripts/generate_version.py") } val distribution by registering(VenvTask::class) { + dependsOn(build) doFirst { delete("README.md") generatePypiProjectHomePage() delete("dist") } - venvExec = "Python3" + venvExec = "python" args = listOf("setup.py", "sdist") doLast { @@ -206,6 +210,6 @@ tasks { it.name.endsWith("envSetup") }.all { // add install package and code formatting before any tasks - finalizedBy(pipInstall, black, pylint) + finalizedBy(pipInstall, black, pylint, build) } } diff --git a/clients/client-python/gravitino/client/gravitino_client.py b/clients/client-python/gravitino/client/gravitino_client.py index 842652125b3..509c465e4c4 100644 --- a/clients/client-python/gravitino/client/gravitino_client.py +++ b/clients/client-python/gravitino/client/gravitino_client.py @@ -34,7 +34,7 @@ class GravitinoClient(GravitinoClientBase): _metalake: GravitinoMetalake - def __init__(self, uri: str, metalake_name: str): + def __init__(self, uri: str, metalake_name: str, check_version: bool = True): """Constructs a new GravitinoClient with the given URI, authenticator and AuthDataProvider. Args: @@ -45,7 +45,7 @@ def __init__(self, uri: str, metalake_name: str): Raises: NoSuchMetalakeException if the metalake with specified name does not exist. """ - super().__init__(uri) + super().__init__(uri, check_version) self._metalake = super().load_metalake(NameIdentifier.of(metalake_name)) def get_metalake(self) -> GravitinoMetalake: diff --git a/clients/client-python/gravitino/client/gravitino_client_base.py b/clients/client-python/gravitino/client/gravitino_client_base.py index cf17d1e6c39..767956e04b8 100644 --- a/clients/client-python/gravitino/client/gravitino_client_base.py +++ b/clients/client-python/gravitino/client/gravitino_client_base.py @@ -4,12 +4,18 @@ """ import logging +import configparser +import os.path from gravitino.client.gravitino_metalake import GravitinoMetalake from gravitino.client.gravitino_version import GravitinoVersion +from gravitino.dto.version_dto import VersionDTO from gravitino.dto.responses.metalake_response import MetalakeResponse +from gravitino.dto.responses.version_response import VersionResponse from gravitino.name_identifier import NameIdentifier from gravitino.utils import HTTPClient +from gravitino.exceptions.gravitino_runtime_exception import GravitinoRuntimeException +from gravitino.constants.version import VERSION_INI, Version logger = logging.getLogger(__name__) @@ -29,8 +35,10 @@ class GravitinoClientBase: API_METALAKES_IDENTIFIER_PATH = f"{API_METALAKES_LIST_PATH}/" """The REST API path prefix for load a specific metalake""" - def __init__(self, uri: str): + def __init__(self, uri: str, check_version: bool = True): self._rest_client = HTTPClient(uri) + if check_version: + self.check_version() def load_metalake(self, ident: NameIdentifier) -> GravitinoMetalake: """Loads a specific Metalake from the Gravitino API. @@ -57,16 +65,53 @@ def load_metalake(self, ident: NameIdentifier) -> GravitinoMetalake: return GravitinoMetalake(metalake_response.metalake(), self._rest_client) - def get_version(self) -> GravitinoVersion: + def check_version(self): + """Check the compatibility of the client with the target server. + + Raises: + GravitinoRuntimeException If the client version is greater than the server version. + """ + server_version = self.get_server_version() + client_version = self.get_client_version() + + if client_version > server_version: + raise GravitinoRuntimeException( + "Gravitino does not support the case that " + "the client-side version is higher than the server-side version." + f"The client version is {client_version.version()}, and the server version {server_version.version()}" + ) + + def get_client_version(self) -> GravitinoVersion: + """Retrieves the version of the Gravitino Python Client. + + Returns: + A GravitinoVersion instance representing the version of the Gravitino Python Client. + """ + config = configparser.ConfigParser() + + if not os.path.exists(VERSION_INI): + raise GravitinoRuntimeException( + f"Failed to get Gravitino version, version file '{VERSION_INI}' does not exist." + ) + config.read(VERSION_INI) + + version = config["metadata"][Version.VERSION.value] + compile_date = config["metadata"][Version.COMPILE_DATE.value] + git_commit = config["metadata"][Version.GIT_COMMIT.value] + + return GravitinoVersion(VersionDTO(version, compile_date, git_commit)) + + def get_server_version(self) -> GravitinoVersion: """Retrieves the version of the Gravitino API. Returns: A GravitinoVersion instance representing the version of the Gravitino API. """ resp = self._rest_client.get("api/version") - resp.validate() + version_response = VersionResponse.from_json(resp.body, infer_missing=True) + version_response.validate() - return GravitinoVersion(resp.get_version()) + return GravitinoVersion(version_response.version()) def close(self): """Closes the GravitinoClient and releases any underlying resources.""" diff --git a/clients/client-python/gravitino/client/gravitino_version.py b/clients/client-python/gravitino/client/gravitino_version.py index f3f004bbf60..793ecf07b23 100644 --- a/clients/client-python/gravitino/client/gravitino_version.py +++ b/clients/client-python/gravitino/client/gravitino_version.py @@ -3,16 +3,68 @@ This software is licensed under the Apache License version 2. """ +import re from dataclasses import dataclass +from enum import Enum from gravitino.dto.version_dto import VersionDTO +from gravitino.exceptions.gravitino_runtime_exception import GravitinoRuntimeException + + +class Version(Enum): + MAJOR = "MAJOR" + MINOR = "MINOR" + PATCH = "PATCH" + + +VERSION_PATTERN: str = ( + rf"(?P<{Version.MAJOR.value}>\d+)\.(?P<{Version.MINOR.value}>\d+)\.(?P<{Version.PATCH.value}>\d+)+?" +) @dataclass class GravitinoVersion(VersionDTO): """Gravitino version information.""" + major: int + minor: int + patch: int + def __init__(self, versionDTO): super().__init__( - versionDTO.version, versionDTO.compile_date, versionDTO.git_commit + versionDTO.version(), versionDTO.compile_date(), versionDTO.git_commit() ) + + m = re.match(VERSION_PATTERN, self.version()) + + assert m is not None, "Invalid version string " + self.version() + + self.major = int(m.group(Version.MAJOR.value)) + self.minor = int(m.group(Version.MINOR.value)) + self.patch = int(m.group(Version.PATCH.value)) + + def __gt__(self, other) -> bool: + if not isinstance(other, GravitinoVersion): + raise GravitinoRuntimeException( + f"{GravitinoVersion.__name__} can't compare with {other.__class__.__name__}" + ) + if self.major > other.major: + return True + if self.minor > other.minor: + return True + if self.patch > other.patch: + return True + return False + + def __eq__(self, other) -> bool: + if not isinstance(other, GravitinoVersion): + raise GravitinoRuntimeException( + f"{GravitinoVersion.__name__} can't compare with {other.__class__.__name__}" + ) + if self.major != other.major: + return False + if self.minor != other.minor: + return False + if self.patch != other.patch: + return False + return True diff --git a/clients/client-python/gravitino/constants/__init__.py b/clients/client-python/gravitino/constants/__init__.py new file mode 100644 index 00000000000..5779a3ad252 --- /dev/null +++ b/clients/client-python/gravitino/constants/__init__.py @@ -0,0 +1,4 @@ +""" +Copyright 2024 Datastrato Pvt Ltd. +This software is licensed under the Apache License version 2. +""" diff --git a/clients/client-python/gravitino/constants.py b/clients/client-python/gravitino/constants/timeout.py similarity index 100% rename from clients/client-python/gravitino/constants.py rename to clients/client-python/gravitino/constants/timeout.py diff --git a/clients/client-python/gravitino/constants/version.py b/clients/client-python/gravitino/constants/version.py new file mode 100644 index 00000000000..13dcd6b20fa --- /dev/null +++ b/clients/client-python/gravitino/constants/version.py @@ -0,0 +1,17 @@ +""" +Copyright 2024 Datastrato Pvt Ltd. +This software is licensed under the Apache License version 2. +""" + +from enum import Enum +from pathlib import Path + +PROJECT_HOME = Path(__file__).parent.parent.parent +VERSION_INI = PROJECT_HOME / "version.ini" +SETUP_FILE = PROJECT_HOME / "setup.py" + + +class Version(Enum): + VERSION = "version" + GIT_COMMIT = "gitCommit" + COMPILE_DATE = "compileDate" diff --git a/clients/client-python/gravitino/dto/responses/version_response.py b/clients/client-python/gravitino/dto/responses/version_response.py new file mode 100644 index 00000000000..c410d910313 --- /dev/null +++ b/clients/client-python/gravitino/dto/responses/version_response.py @@ -0,0 +1,39 @@ +""" +Copyright 2024 Datastrato Pvt Ltd. +This software is licensed under the Apache License version 2. +""" + +from dataclasses import dataclass, field +from dataclasses_json import config + +from .base_response import BaseResponse +from ..version_dto import VersionDTO + + +@dataclass +class VersionResponse(BaseResponse): + """Represents a response containing version of Gravitino.""" + + _version: VersionDTO = field(metadata=config(field_name="version")) + + def version(self) -> VersionDTO: + return self._version + + def validate(self): + """Validates the response data. + + Raise: + IllegalArgumentException if name or audit information is not set. + """ + super().validate() + + assert self._version is not None, "version must be non-null" + assert ( + self._version.version() is not None + ), "version 'version' must not be null and empty" + assert ( + self._version.compile_date() is not None + ), "version 'compile_date' must not be null and empty" + assert ( + self._version.git_commit() is not None + ), "version 'git_commit' must not be null and empty" diff --git a/clients/client-python/gravitino/dto/version_dto.py b/clients/client-python/gravitino/dto/version_dto.py index 5a56b638af3..ded08bef90b 100644 --- a/clients/client-python/gravitino/dto/version_dto.py +++ b/clients/client-python/gravitino/dto/version_dto.py @@ -3,18 +3,28 @@ This software is licensed under the Apache License version 2. """ -from dataclasses import dataclass +from dataclasses import dataclass, field +from dataclasses_json import config @dataclass class VersionDTO: """Represents a Version Data Transfer Object (DTO).""" - version: str = "" + _version: str = field(metadata=config(field_name="version")) """The version of the software.""" - compile_date: str = "" + _compile_date: str = field(metadata=config(field_name="compileDate")) """The date the software was compiled.""" - git_commit: str = "" + _git_commit: str = field(metadata=config(field_name="gitCommit")) """The git commit of the software.""" + + def version(self) -> str: + return self._version + + def compile_date(self) -> str: + return self._compile_date + + def git_commit(self) -> str: + return self._git_commit diff --git a/clients/client-python/gravitino/exceptions/gravitino_runtime_exception.py b/clients/client-python/gravitino/exceptions/gravitino_runtime_exception.py index 520b6a7ec5a..0797f8ec70d 100644 --- a/clients/client-python/gravitino/exceptions/gravitino_runtime_exception.py +++ b/clients/client-python/gravitino/exceptions/gravitino_runtime_exception.py @@ -8,4 +8,4 @@ class GravitinoRuntimeException(RuntimeError): """Base class for all Gravitino runtime exceptions.""" def __init__(self, message, *args): - super().__init__(message.format(*args)) + super().__init__(message % args) diff --git a/clients/client-python/gravitino/utils/http_client.py b/clients/client-python/gravitino/utils/http_client.py index 08d0a55d649..163c5907c25 100644 --- a/clients/client-python/gravitino/utils/http_client.py +++ b/clients/client-python/gravitino/utils/http_client.py @@ -30,7 +30,7 @@ from gravitino.typing import JSONType from gravitino.utils.exceptions import handle_error -from gravitino.constants import TIMEOUT +from gravitino.constants.timeout import TIMEOUT logger = logging.getLogger(__name__) diff --git a/clients/client-python/scripts/__init__.py b/clients/client-python/scripts/__init__.py new file mode 100644 index 00000000000..5779a3ad252 --- /dev/null +++ b/clients/client-python/scripts/__init__.py @@ -0,0 +1,4 @@ +""" +Copyright 2024 Datastrato Pvt Ltd. +This software is licensed under the Apache License version 2. +""" diff --git a/clients/client-python/scripts/generate_version.py b/clients/client-python/scripts/generate_version.py new file mode 100644 index 00000000000..c55a5f7536b --- /dev/null +++ b/clients/client-python/scripts/generate_version.py @@ -0,0 +1,45 @@ +""" +Copyright 2024 Datastrato Pvt Ltd. +This software is licensed under the Apache License version 2. +""" + +import re +import configparser +import subprocess +from datetime import datetime + +from gravitino.constants.version import Version, VERSION_INI, SETUP_FILE +from gravitino.exceptions.gravitino_runtime_exception import GravitinoRuntimeException + +VERSION_PATTERN = r"version\s*=\s*['\"]([^'\"]+)['\"]" + + +def main(): + with open(SETUP_FILE, "r", encoding="utf-8") as f: + setup_content = f.read() + m = re.search(VERSION_PATTERN, setup_content) + if m is not None: + version = m.group(1) + else: + raise GravitinoRuntimeException("Can't find valid version info in setup.py") + + git_commit = ( + subprocess.check_output(["git", "rev-parse", "HEAD"]).decode("ascii").strip() + ) + + compile_date = datetime.now().strftime("%d/%m/%Y %H:%M:%S") + + config = configparser.ConfigParser() + config.optionxform = str + config["metadata"] = { + Version.VERSION.value: version, + Version.GIT_COMMIT.value: git_commit, + Version.COMPILE_DATE.value: compile_date, + } + + with open(VERSION_INI, "w", encoding="utf-8") as f: + config.write(f) + + +if __name__ == "__main__": + main() diff --git a/clients/client-python/setup.py b/clients/client-python/setup.py index 9ec5d4a13ea..afaf188d3b2 100644 --- a/clients/client-python/setup.py +++ b/clients/client-python/setup.py @@ -22,7 +22,7 @@ author="datastrato", author_email="support@datastrato.com", python_requires=">=3.8", - packages=find_packages(exclude=["tests*"]), + packages=find_packages(exclude=["tests*", "scripts*"]), classifiers=[ 'Development Status :: 3 - Alpha', 'Intended Audience :: Developers', diff --git a/clients/client-python/tests/unittests/test_gravitino_version.py b/clients/client-python/tests/unittests/test_gravitino_version.py new file mode 100644 index 00000000000..347cca112b2 --- /dev/null +++ b/clients/client-python/tests/unittests/test_gravitino_version.py @@ -0,0 +1,90 @@ +""" +Copyright 2024 Datastrato Pvt Ltd. +This software is licensed under the Apache License version 2. +""" + +import unittest + +from gravitino.client.gravitino_version import GravitinoVersion +from gravitino.dto.version_dto import VersionDTO +from gravitino.exceptions.gravitino_runtime_exception import GravitinoRuntimeException + + +class TestGravitinoVersion(unittest.TestCase): + def test_parse_version_string(self): + # Test a valid the version string + version = GravitinoVersion(VersionDTO("0.6.0", "2023-01-01", "1234567")) + + self.assertEqual(version.major, 0) + self.assertEqual(version.minor, 6) + self.assertEqual(version.patch, 0) + + # Test a valid the version string with SNAPSHOT + version = GravitinoVersion( + VersionDTO("0.6.0-SNAPSHOT", "2023-01-01", "1234567") + ) + + self.assertEqual(version.major, 0) + self.assertEqual(version.minor, 6) + self.assertEqual(version.patch, 0) + + # Test a valid the version string with alpha + version = GravitinoVersion(VersionDTO("0.6.0-alpha", "2023-01-01", "1234567")) + + self.assertEqual(version.major, 0) + self.assertEqual(version.minor, 6) + self.assertEqual(version.patch, 0) + + # Test a valid the version string with pypi format + version = GravitinoVersion(VersionDTO("0.6.0.dev21", "2023-01-01", "1234567")) + + self.assertEqual(version.major, 0) + self.assertEqual(version.minor, 6) + self.assertEqual(version.patch, 0) + + # Test an invalid the version string with 2 part + self.assertRaises( + AssertionError, GravitinoVersion, VersionDTO("0.6", "2023-01-01", "1234567") + ) + + # Test an invalid the version string with not number + self.assertRaises( + AssertionError, + GravitinoVersion, + VersionDTO("a.b.c", "2023-01-01", "1234567"), + ) + + def test_version_compare(self): + # test equal + version1 = GravitinoVersion(VersionDTO("0.6.0", "2023-01-01", "1234567")) + version2 = GravitinoVersion(VersionDTO("0.6.0", "2023-01-01", "1234567")) + + self.assertEqual(version1, version2) + + # test less than + version1 = GravitinoVersion(VersionDTO("0.6.0", "2023-01-01", "1234567")) + version2 = GravitinoVersion(VersionDTO("0.12.0", "2023-01-01", "1234567")) + + self.assertLess(version1, version2) + + # test greater than + version1 = GravitinoVersion(VersionDTO("1.6.0", "2023-01-01", "1234567")) + version2 = GravitinoVersion(VersionDTO("0.6.0", "2023-01-01", "1234567")) + + self.assertGreater(version1, version2) + + # test equal with suffix + version1 = GravitinoVersion( + VersionDTO("0.6.0-SNAPSHOT", "2023-01-01", "1234567") + ) + version2 = GravitinoVersion(VersionDTO("0.6.0", "2023-01-01", "1234567")) + + self.assertEqual(version1, version2) + + # test compare with other class + + version1 = GravitinoVersion(VersionDTO("0.6.0", "2023-01-01", "1234567")) + version2 = "0.6.0" + + self.assertRaises(GravitinoRuntimeException, version1.__eq__, version2) + self.assertRaises(GravitinoRuntimeException, version1.__gt__, version2)