Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ cython_debug/

.DS_Store
CLAUDE.md
AGENTS.md

.cursor/
results/
Expand Down
99 changes: 97 additions & 2 deletions src/memfuse/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion src/memfuse/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
152 changes: 152 additions & 0 deletions src/memfuse/utils/version_compatibility.py
Original file line number Diff line number Diff line change
@@ -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)
24 changes: 23 additions & 1 deletion tests/smoke/test_smoke_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
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")
Loading