From fab8cd27e4f2168be1d56f6b62031259b823607b Mon Sep 17 00:00:00 2001 From: Calvin Ku Date: Tue, 2 Sep 2025 17:27:19 +0800 Subject: [PATCH] feat: add version field to health endpoint for SDK compatibility checking Add version compatibility utility and update health endpoint to include version information for SDK compatibility checking as requested in issue #116. --- .gitignore | 1 + src/memfuse/client.py | 99 +++++++- src/memfuse/utils/__init__.py | 3 +- src/memfuse/utils/version_compatibility.py | 152 ++++++++++++ tests/smoke/test_smoke_basic.py | 24 +- tests/unit/test_version_compatibility.py | 275 +++++++++++++++++++++ 6 files changed, 550 insertions(+), 4 deletions(-) create mode 100644 src/memfuse/utils/version_compatibility.py create mode 100644 tests/unit/test_version_compatibility.py diff --git a/.gitignore b/.gitignore index c74e075..bf260c7 100644 --- a/.gitignore +++ b/.gitignore @@ -176,6 +176,7 @@ cython_debug/ .DS_Store CLAUDE.md +AGENTS.md .cursor/ results/ diff --git a/src/memfuse/client.py b/src/memfuse/client.py index 5a55f89..99d2cb7 100644 --- a/src/memfuse/client.py +++ b/src/memfuse/client.py @@ -4,12 +4,13 @@ import asyncio import threading import aiohttp -from typing import Dict, Optional, Any, List +from typing import Dict, Optional, Any import uuid import time +from loguru import logger from .memory import AsyncMemory -from .utils import MemFuseHTTPError +from .utils import MemFuseHTTPError, check_version_compatibility from .api import ( HealthApi, UsersApi, @@ -147,6 +148,50 @@ async def _request( " poetry run memfuse-core" ) from e + async def _check_version_compatibility(self): + """Check SDK and server version compatibility and display warnings if needed.""" + try: + # Get SDK version + from . import __version__ + sdk_version = __version__ + + # If version is placeholder, try to get it from installed package metadata + if not sdk_version or sdk_version == "{{version}}": + try: + # Try to get version from installed package + from importlib.metadata import version, PackageNotFoundError + try: + sdk_version = version('memfuse') + except PackageNotFoundError: + # Package not installed via pip, skip version check + pass + except ImportError: + # Python < 3.8, try backport + try: + from importlib_metadata import version, PackageNotFoundError + try: + sdk_version = version('memfuse') + except PackageNotFoundError: + pass + except ImportError: + pass + + if not sdk_version or sdk_version == "{{version}}": + logger.debug("SDK version not available, skipping version check") + return + + # Get server health information + health_data = await self.health.health_check() + + # Check compatibility and print warning if needed + warning = check_version_compatibility(sdk_version, health_data) + if warning: + print(warning) + + except Exception as e: + # Don't fail init if version check fails + logger.debug(f"Version compatibility check failed: {e}") + async def init( self, user: str, @@ -163,6 +208,9 @@ async def init( Returns: ClientMemory: A client memory instance for the specified user, agent, and session """ + # Check version compatibility + await self._check_version_compatibility() + # Get or create user user_name = user try: @@ -499,6 +547,50 @@ def _request_sync( " poetry run memfuse-core" ) from e + def _check_version_compatibility_sync(self): + """Check SDK and server version compatibility and display warnings if needed (sync version).""" + try: + # Get SDK version + from . import __version__ + sdk_version = __version__ + + # If version is placeholder, try to get it from installed package metadata + if not sdk_version or sdk_version == "{{version}}": + try: + # Try to get version from installed package + from importlib.metadata import version, PackageNotFoundError + try: + sdk_version = version('memfuse') + except PackageNotFoundError: + # Package not installed via pip, skip version check + pass + except ImportError: + # Python < 3.8, try backport + try: + from importlib_metadata import version, PackageNotFoundError + try: + sdk_version = version('memfuse') + except PackageNotFoundError: + pass + except ImportError: + pass + + if not sdk_version or sdk_version == "{{version}}": + logger.debug("SDK version not available, skipping version check") + return + + # Get server health information + health_data = self.health.health_check_sync() + + # Check compatibility and print warning if needed + warning = check_version_compatibility(sdk_version, health_data) + if warning: + print(warning) + + except Exception as e: + # Don't fail init if version check fails + logger.debug(f"Version compatibility check failed: {e}") + def init( self, user: str, @@ -515,6 +607,9 @@ def init( Returns: Memory: A synchronous memory instance. """ + # Check version compatibility + self._check_version_compatibility_sync() + # Get or create user user_name = user try: diff --git a/src/memfuse/utils/__init__.py b/src/memfuse/utils/__init__.py index 27bc2c8..31201c3 100644 --- a/src/memfuse/utils/__init__.py +++ b/src/memfuse/utils/__init__.py @@ -1,5 +1,6 @@ """Client utility functions for MemFuse.""" from .error_handling import handle_server_connection, run_async, run_with_error_handling, MemFuseHTTPError +from .version_compatibility import check_version_compatibility -__all__ = ["handle_server_connection", "run_async", "run_with_error_handling", "MemFuseHTTPError"] +__all__ = ["handle_server_connection", "run_async", "run_with_error_handling", "MemFuseHTTPError", "check_version_compatibility"] diff --git a/src/memfuse/utils/version_compatibility.py b/src/memfuse/utils/version_compatibility.py new file mode 100644 index 0000000..70765b0 --- /dev/null +++ b/src/memfuse/utils/version_compatibility.py @@ -0,0 +1,152 @@ +"""Version compatibility utilities for MemFuse SDK.""" + +import re +from loguru import logger +from typing import Optional, Tuple, Dict, Any + +# GitHub URLs +SDK_GITHUB_URL = "https://github.com/memfuse/memfuse-python" +SERVER_GITHUB_URL = "https://github.com/memfuse/memfuse" + + +def parse_semantic_version(version_string: str) -> Optional[Tuple[int, int, int]]: + """Parse a semantic version string into (major, minor, patch) tuple. + + Args: + version_string: Version string like "1.2.3" or "v1.2.3" or "1.2.3.post16.dev0+hash" + + Returns: + Tuple of (major, minor, patch) integers, or None if parsing fails + """ + if not version_string: + return None + + # Remove 'v' prefix if present + version_string = version_string.lstrip('v') + + # Match semantic version pattern with more flexible post-release/dev/local identifiers + # This handles: 1.2.3, 1.2.3-alpha, 1.2.3+build.1, 1.2.3.post16.dev0+hash, etc. + pattern = r'^(\d+)\.(\d+)\.(\d+)(?:[-+.].*)?$' + match = re.match(pattern, version_string) + + if not match: + logger.debug(f"Failed to parse version string: {version_string}") + return None + + try: + return int(match.group(1)), int(match.group(2)), int(match.group(3)) + except ValueError: + logger.debug(f"Failed to convert version components to integers: {version_string}") + return None + + +def compare_versions(sdk_version: str, server_version: str) -> Optional[str]: + """Compare SDK and server versions and return recommendation if needed. + + Args: + sdk_version: SDK version string + server_version: Server version string + + Returns: + Warning message if versions are incompatible, None if compatible or parsing fails + """ + sdk_parsed = parse_semantic_version(sdk_version) + server_parsed = parse_semantic_version(server_version) + + if not sdk_parsed or not server_parsed: + logger.debug(f"Could not parse versions - SDK: {sdk_version}, Server: {server_version}") + return None + + sdk_major, sdk_minor, sdk_patch = sdk_parsed + server_major, server_minor, server_patch = server_parsed + + # Only compare minor versions as requested + if sdk_minor == server_minor: + return None + + # Generate appropriate warning message + if sdk_minor < server_minor: + recommendation = ( + f"Please upgrade your SDK to match the server version:\n" + f" pip install --upgrade memfuse" + ) + else: + recommendation = ( + f"Please upgrade your server to match the SDK version.\n" + f" Your server version ({server_version}) is behind the SDK version ({sdk_version})." + ) + + warning_message = ( + f"⚠️ Version mismatch detected:\n" + f" SDK version: {sdk_version}\n" + f" Server version: {server_version}\n\n" + f" {recommendation}\n\n" + f" SDK: {SDK_GITHUB_URL}\n" + f" Server: {SERVER_GITHUB_URL}" + ) + + return warning_message + + +def extract_version_from_health_response(health_data: Dict[str, Any]) -> Optional[str]: + """Extract version information from health check response. + + Based on the actual MemFuse server response format: + { + "status": "success", + "code": 200, + "data": { + "status": "ok", + "version": "0.3.1", + ... + } + } + + Args: + health_data: Response data from /api/v1/health endpoint + + Returns: + Version string if found, None otherwise + """ + if not isinstance(health_data, dict): + return None + + # Check the known structure: data.version + if 'data' in health_data and isinstance(health_data['data'], dict): + if 'version' in health_data['data']: + version = health_data['data']['version'] + if isinstance(version, str) and version.strip(): + return version.strip() + + # Fallback: try common fields at top level + version_fields = ['version', 'server_version', 'api_version'] + for field in version_fields: + if field in health_data: + version = health_data[field] + if isinstance(version, str) and version.strip(): + return version.strip() + + logger.debug(f"No version found in health response keys: {list(health_data.keys())}") + return None + + +def check_version_compatibility(sdk_version: str, health_data: Dict[str, Any]) -> Optional[str]: + """Check version compatibility and return warning if needed. + + Args: + sdk_version: Current SDK version + health_data: Response data from health check endpoint + + Returns: + Warning message if versions are incompatible, None if compatible + """ + if not sdk_version or not health_data: + return None + + server_version = extract_version_from_health_response(health_data) + + if not server_version: + logger.debug("Could not extract server version from health response") + return None + + return compare_versions(sdk_version, server_version) \ No newline at end of file diff --git a/tests/smoke/test_smoke_basic.py b/tests/smoke/test_smoke_basic.py index e9c3b76..2435fdd 100644 --- a/tests/smoke/test_smoke_basic.py +++ b/tests/smoke/test_smoke_basic.py @@ -345,4 +345,26 @@ def test_sync_client_full_cleanup(): assert client.sync_session is None mock_session.close.assert_called_once() - print("✅ Sync client full cleanup works properly") \ No newline at end of file + print("✅ Sync client full cleanup works properly") + + +@pytest.mark.smoke +def test_version_compatibility_methods_exist(): + """Test that version compatibility checking methods exist on clients.""" + from memfuse import AsyncMemFuse, MemFuse + + async_client = AsyncMemFuse() + sync_client = MemFuse() + + # Check that version compatibility methods exist + assert hasattr(async_client, '_check_version_compatibility') + assert hasattr(sync_client, '_check_version_compatibility_sync') + assert callable(async_client._check_version_compatibility) + assert callable(sync_client._check_version_compatibility_sync) + + # Verify async method is actually async + import asyncio + assert asyncio.iscoroutinefunction(async_client._check_version_compatibility) + assert not asyncio.iscoroutinefunction(sync_client._check_version_compatibility_sync) + + print("✅ Version compatibility methods are available") \ No newline at end of file diff --git a/tests/unit/test_version_compatibility.py b/tests/unit/test_version_compatibility.py new file mode 100644 index 0000000..9f5b24f --- /dev/null +++ b/tests/unit/test_version_compatibility.py @@ -0,0 +1,275 @@ +"""Unit tests for version compatibility utilities.""" + +import pytest +from unittest.mock import Mock, AsyncMock, patch +from memfuse.utils.version_compatibility import ( + parse_semantic_version, + compare_versions, + extract_version_from_health_response, + check_version_compatibility +) + + +class TestVersionParsing: + """Test semantic version parsing functionality.""" + + def test_parse_valid_semantic_versions(self): + """Test parsing of valid semantic version strings.""" + # Standard semantic versions + assert parse_semantic_version("1.2.3") == (1, 2, 3) + assert parse_semantic_version("0.1.0") == (0, 1, 0) + assert parse_semantic_version("10.20.30") == (10, 20, 30) + + # Versions with 'v' prefix + assert parse_semantic_version("v1.2.3") == (1, 2, 3) + assert parse_semantic_version("v0.1.0") == (0, 1, 0) + + # Versions with pre-release/build metadata + assert parse_semantic_version("1.2.3-alpha") == (1, 2, 3) + assert parse_semantic_version("1.2.3+build.1") == (1, 2, 3) + assert parse_semantic_version("1.2.3-alpha+build.1") == (1, 2, 3) + + # Python development versions (PEP 440 style) + assert parse_semantic_version("0.3.0.post16.dev0+899f75a") == (0, 3, 0) + assert parse_semantic_version("1.0.0.dev123") == (1, 0, 0) + assert parse_semantic_version("2.1.0.post1") == (2, 1, 0) + + # Complex versions that should extract base version + assert parse_semantic_version("1.2.3.4") == (1, 2, 3) # Treats .4 as suffix + + def test_parse_invalid_versions(self): + """Test parsing of invalid version strings.""" + # Empty or None + assert parse_semantic_version("") is None + assert parse_semantic_version(None) is None + + # Invalid formats (not enough version components) + assert parse_semantic_version("1.2") is None + assert parse_semantic_version("v1.2") is None + assert parse_semantic_version("abc.def.ghi") is None + assert parse_semantic_version("1.2.x") is None + + +class TestVersionComparison: + """Test version comparison logic.""" + + def test_compatible_versions(self): + """Test that versions with same minor version are compatible.""" + # Same minor version should be compatible + assert compare_versions("0.1.0", "0.1.5") is None + assert compare_versions("0.2.1", "0.2.0") is None + assert compare_versions("1.0.0", "1.0.10") is None + assert compare_versions("2.5.3", "2.5.1") is None + + def test_sdk_behind_server(self): + """Test warning when SDK minor version is behind server.""" + warning = compare_versions("0.1.5", "0.2.1") + assert warning is not None + assert "SDK version: 0.1.5" in warning + assert "Server version: 0.2.1" in warning + assert "Please upgrade your SDK" in warning + assert "pip install --upgrade memfuse" in warning + assert "github.com/memfuse/memfuse-python" in warning + assert "github.com/memfuse/memfuse" in warning + + def test_server_behind_sdk(self): + """Test warning when server minor version is behind SDK.""" + warning = compare_versions("0.3.1", "0.2.5") + assert warning is not None + assert "SDK version: 0.3.1" in warning + assert "Server version: 0.2.5" in warning + assert "Please upgrade your server" in warning + assert "server version (0.2.5) is behind the SDK version (0.3.1)" in warning + assert "github.com/memfuse/memfuse-python" in warning + assert "github.com/memfuse/memfuse" in warning + + def test_invalid_version_strings(self): + """Test that invalid version strings return None (no warning).""" + assert compare_versions("invalid", "0.1.0") is None + assert compare_versions("0.1.0", "invalid") is None + assert compare_versions("", "0.1.0") is None + assert compare_versions("0.1.0", "") is None + + +class TestHealthResponseParsing: + """Test extraction of version info from health responses.""" + + def test_extract_version_from_standard_response(self): + """Test extracting version from standard MemFuse health response.""" + health_data = { + "status": "success", + "code": 200, + "data": { + "status": "ok", + "version": "0.3.1", + "python_version": "3.11.9" + } + } + assert extract_version_from_health_response(health_data) == "0.3.1" + + def test_extract_version_from_top_level(self): + """Test extracting version from top-level fields.""" + health_data = {"version": "1.2.3", "status": "ok"} + assert extract_version_from_health_response(health_data) == "1.2.3" + + health_data = {"server_version": "2.0.1"} + assert extract_version_from_health_response(health_data) == "2.0.1" + + def test_extract_version_with_whitespace(self): + """Test that version extraction handles whitespace.""" + health_data = {"data": {"version": " 0.3.1 "}} + assert extract_version_from_health_response(health_data) == "0.3.1" + + def test_no_version_found(self): + """Test handling when no version is found.""" + # Empty response + assert extract_version_from_health_response({}) is None + + # No version field + health_data = {"status": "ok", "data": {"status": "healthy"}} + assert extract_version_from_health_response(health_data) is None + + # Invalid data type + assert extract_version_from_health_response("invalid") is None + assert extract_version_from_health_response(None) is None + + def test_extract_from_nested_structures(self): + """Test extracting version from various nested structures.""" + # Note: Current implementation only checks specific nested keys + # Test the ones that are actually implemented + health_data = {"data": {"version": "1.0.0"}} + assert extract_version_from_health_response(health_data) == "1.0.0" + + +class TestVersionCompatibilityIntegration: + """Test the main version compatibility check function.""" + + def test_check_compatibility_success(self): + """Test successful compatibility check.""" + health_data = { + "status": "success", + "data": {"version": "0.2.1"} + } + + # Compatible versions + warning = check_version_compatibility("0.2.0", health_data) + assert warning is None + + # Incompatible versions + warning = check_version_compatibility("0.1.5", health_data) + assert warning is not None + assert "SDK version: 0.1.5" in warning + assert "Server version: 0.2.1" in warning + + def test_check_compatibility_missing_data(self): + """Test handling of missing or invalid data.""" + # Missing SDK version + assert check_version_compatibility("", {"data": {"version": "0.1.0"}}) is None + assert check_version_compatibility(None, {"data": {"version": "0.1.0"}}) is None + + # Missing health data + assert check_version_compatibility("0.1.0", {}) is None + assert check_version_compatibility("0.1.0", None) is None + + # No version in health data + health_data = {"status": "success", "data": {"status": "ok"}} + assert check_version_compatibility("0.1.0", health_data) is None + + +class TestClientVersionCheckIntegration: + """Test version checking integration with MemFuse clients.""" + + @pytest.mark.asyncio + async def test_async_client_version_check_method_exists(self): + """Test that async client has version checking method.""" + from memfuse import AsyncMemFuse + + client = AsyncMemFuse() + assert hasattr(client, '_check_version_compatibility') + assert callable(client._check_version_compatibility) + + # Should be async + import asyncio + assert asyncio.iscoroutinefunction(client._check_version_compatibility) + + def test_sync_client_version_check_method_exists(self): + """Test that sync client has version checking method.""" + from memfuse import MemFuse + + client = MemFuse() + assert hasattr(client, '_check_version_compatibility_sync') + assert callable(client._check_version_compatibility_sync) + + # Should be sync + import asyncio + assert not asyncio.iscoroutinefunction(client._check_version_compatibility_sync) + + @pytest.mark.asyncio + async def test_async_version_check_with_mocked_health(self): + """Test async version checking behavior.""" + from memfuse import AsyncMemFuse + + client = AsyncMemFuse() + + # Test that method executes without errors when mocked + with patch.object(client.health, 'health_check', return_value={"data": {"version": "0.3.1"}}): + # Should execute without raising exceptions + await client._check_version_compatibility() + + def test_sync_version_check_with_mocked_health(self): + """Test sync version checking behavior.""" + from memfuse import MemFuse + + client = MemFuse() + + # Test that method executes without errors when mocked + with patch.object(client.health, 'health_check_sync', return_value={"data": {"version": "0.2.1"}}): + # Should execute without raising exceptions + client._check_version_compatibility_sync() + + @pytest.mark.asyncio + async def test_async_version_check_handles_errors(self): + """Test that async version check handles errors gracefully.""" + from memfuse import AsyncMemFuse + + client = AsyncMemFuse() + + # Mock health check to raise an exception + with patch.object(client.health, 'health_check', side_effect=Exception("Health check failed")): + # Should not raise an exception + try: + await client._check_version_compatibility() + except Exception: + pytest.fail("Version check should handle errors gracefully") + + def test_sync_version_check_handles_errors(self): + """Test that sync version check handles errors gracefully.""" + from memfuse import MemFuse + + client = MemFuse() + + # Mock health check to raise an exception + with patch.object(client.health, 'health_check_sync', side_effect=Exception("Health check failed")): + # Should not raise an exception + try: + client._check_version_compatibility_sync() + except Exception: + pytest.fail("Version check should handle errors gracefully") + + def test_version_check_functionality_basic(self): + """Test basic version checking functionality without complex mocking.""" + # This test focuses on the core logic without the complex import mocking + from memfuse.utils.version_compatibility import check_version_compatibility + + # Test cases that should trigger warnings + health_data = {"data": {"version": "0.3.1"}} + + # SDK behind case + warning = check_version_compatibility("0.2.0", health_data) + assert warning is not None + assert "SDK version: 0.2.0" in warning + assert "Server version: 0.3.1" in warning + + # Compatible case + warning = check_version_compatibility("0.3.0", health_data) + assert warning is None \ No newline at end of file