diff --git a/CHANGELOG.md b/CHANGELOG.md index d0af296fd8aa..96ece1aa44f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Escaping in the `filter` parameter in generated URLs () - Rotation property lost during saving a mutable attribute () +- Server micro version support check in SDK/CLI () ### Security - TDB diff --git a/cvat-sdk/cvat_sdk/core/client.py b/cvat-sdk/cvat_sdk/core/client.py index eafa21f7612d..1db2de999bd6 100644 --- a/cvat-sdk/cvat_sdk/core/client.py +++ b/cvat-sdk/cvat_sdk/core/client.py @@ -13,6 +13,7 @@ from typing import Any, Dict, Iterator, Optional, Sequence, Tuple, TypeVar import attrs +import packaging.specifiers as specifiers import packaging.version as pv import platformdirs import urllib3 @@ -253,25 +254,29 @@ def check_server_version(self, fail_if_unsupported: Optional[bool] = None) -> No raise IncompatibleVersionException(msg) return - sdk_version = pv.Version(VERSION) - - # We only check base version match. Micro releases and fixes do not affect - # API compatibility in general. - if all( - server_version.base_version != sv.base_version for sv in self.SUPPORTED_SERVER_VERSIONS + if not any( + self._is_version_compatible(server_version, supported_version) + for supported_version in self.SUPPORTED_SERVER_VERSIONS ): msg = ( "Server version '%s' is not compatible with SDK version '%s'. " "Some SDK functions may not work properly with this server. " "You can continue using this SDK, or you can " "try to update with 'pip install cvat-sdk'." - ) % (server_version, sdk_version) + ) % (server_version, pv.Version(VERSION)) self.logger.warning(msg) if fail_if_unsupported: raise IncompatibleVersionException(msg) + def _is_version_compatible(self, current: pv.Version, target: pv.Version) -> bool: + # Check for (major, minor) compatibility. + # Micro releases and fixes do not affect API compatibility in general. + epoch = f"{target.epoch}!" if target.epoch else "" # 1.0 ~= 0!1.0 is false + return current in specifiers.Specifier( + f"~= {epoch}{target.major}.{target.minor}.{target.micro}" + ) + def get_server_version(self) -> pv.Version: - # TODO: allow to use this endpoint unauthorized (about, _) = self.api_client.server_api.retrieve_about() return pv.Version(about.version) diff --git a/tests/python/sdk/test_client.py b/tests/python/sdk/test_client.py index 58bfa310c97f..4c3a63837f89 100644 --- a/tests/python/sdk/test_client.py +++ b/tests/python/sdk/test_client.py @@ -5,7 +5,7 @@ import io from contextlib import ExitStack from logging import Logger -from typing import Tuple +from typing import List, Tuple import packaging.version as pv import pytest @@ -160,6 +160,51 @@ def mocked_version(_): assert "Server version '0' is not compatible with SDK version" in logger_stream.getvalue() +@pytest.mark.parametrize( + "server_version, supported_versions, expect_supported", + [ + # Currently, it is ~=, as defined in https://peps.python.org/pep-0440/ + ("3.2", ["2.0"], False), + ("2", ["2.1"], False), + ("2.1", ["2.1"], True), + ("2.1a", ["2.1"], False), + ("2.1.post1", ["2.1"], True), + ("2.1", ["2.1.pre1"], True), + ("2.1.1", ["2.1"], True), + ("2.2", ["2.1"], False), + ("2.2", ["2.1.0", "2.3"], False), + ("2.2", ["2.1", "2.2", "2.3"], True), + ("2.2.post1", ["2.1", "2.2", "2.3"], True), + ("2.2.pre1", ["2.1", "2.2", "2.3"], False), + ("2.2", ["2.3"], False), + ("2.1.0.dev123", ["2.1.post2"], False), + ("1!1.3", ["2.1"], False), + ("1!1.3.1", ["2.1", "1!1.3"], True), + ("1!1.1.dev12", ["1!1.1"], False), + ], +) +def test_can_check_server_version_compatibility( + fxt_logger: Tuple[Logger, io.StringIO], + monkeypatch: pytest.MonkeyPatch, + server_version: str, + supported_versions: List[str], + expect_supported: bool, +): + logger, _ = fxt_logger + + monkeypatch.setattr(Client, "get_server_version", lambda _: pv.Version(server_version)) + monkeypatch.setattr( + Client, "SUPPORTED_SERVER_VERSIONS", [pv.Version(v) for v in supported_versions] + ) + config = Config(allow_unsupported_server=False) + + with ExitStack() as es: + if not expect_supported: + es.enter_context(pytest.raises(IncompatibleVersionException)) + + Client(url=BASE_URL, logger=logger, config=config, check_server_version=True) + + @pytest.mark.parametrize("verify", [True, False]) def test_can_control_ssl_verification_with_config(verify: bool): config = Config(verify_ssl=verify)