memwal 0.1.0.dev3__tar.gz → 0.1.1.dev0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memwal
3
- Version: 0.1.0.dev3
3
+ Version: 0.1.1.dev0
4
4
  Summary: Python SDK for MemWal — Privacy-first AI memory with Ed25519 signing
5
5
  Project-URL: Homepage, https://memwal.ai
6
6
  Project-URL: Documentation, https://docs.memwal.ai
@@ -227,10 +227,10 @@ Create a new async client.
227
227
  Every request is signed with Ed25519:
228
228
 
229
229
  ```
230
- message = f"{timestamp}.{method}.{path}.{sha256(body)}"
230
+ message = f"{timestamp}.{method}.{path_and_query}.{body_sha256}.{nonce}.{account_id}"
231
231
  ```
232
232
 
233
- Headers sent: `x-public-key`, `x-signature`, `x-timestamp`, `x-delegate-key`, `x-account-id`.
233
+ Signed requests send `x-public-key`, `x-signature`, `x-timestamp`, `x-nonce`, and `x-account-id`. Relayer-mode requests also send `x-seal-session`; manual-mode requests omit decrypt credentials.
234
234
 
235
235
  ## License
236
236
 
@@ -188,10 +188,10 @@ Create a new async client.
188
188
  Every request is signed with Ed25519:
189
189
 
190
190
  ```
191
- message = f"{timestamp}.{method}.{path}.{sha256(body)}"
191
+ message = f"{timestamp}.{method}.{path_and_query}.{body_sha256}.{nonce}.{account_id}"
192
192
  ```
193
193
 
194
- Headers sent: `x-public-key`, `x-signature`, `x-timestamp`, `x-delegate-key`, `x-account-id`.
194
+ Signed requests send `x-public-key`, `x-signature`, `x-timestamp`, `x-nonce`, and `x-account-id`. Relayer-mode requests also send `x-seal-session`; manual-mode requests omit decrypt credentials.
195
195
 
196
196
  ## License
197
197
 
@@ -24,6 +24,7 @@ Quick start::
24
24
 
25
25
  from .client import (
26
26
  MemWal,
27
+ MemWalCompatibilityError,
27
28
  MemWalError,
28
29
  MemWalRememberJobFailed,
29
30
  MemWalRememberJobNotFound,
@@ -70,6 +71,7 @@ __all__ = [
70
71
  "MemWal",
71
72
  "MemWalSync",
72
73
  "MemWalError",
74
+ "MemWalCompatibilityError",
73
75
  "MemWalRememberJobFailed",
74
76
  "MemWalRememberJobNotFound",
75
77
  "MemWalRememberJobTimeout",
@@ -110,4 +112,4 @@ __all__ = [
110
112
  "RecallManualResult",
111
113
  ]
112
114
 
113
- __version__ = "0.1.0.dev3"
115
+ __version__ = "0.1.1.dev0"
@@ -35,6 +35,7 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple, TypeVar
35
35
  import httpx
36
36
  import nacl.signing
37
37
 
38
+ from .compatibility import compatibility_error
38
39
  from .types import (
39
40
  AnalyzedFact,
40
41
  AnalyzeResult,
@@ -134,6 +135,8 @@ class MemWal:
134
135
  self._server_config: Optional[Dict[str, str]] = None
135
136
  self._session_cache: Optional[Tuple[str, int]] = None
136
137
  self._session_build_task: Optional[asyncio.Task[str]] = None
138
+ self._relayer_version_metadata: Optional[Dict[str, Any]] = None
139
+ self._compatibility_lock: Optional[asyncio.Lock] = None
137
140
 
138
141
  @classmethod
139
142
  def create(
@@ -652,7 +655,22 @@ class MemWal:
652
655
  if response.status_code != 200:
653
656
  raise MemWalError(f"Health check failed: {response.status_code}")
654
657
  data = response.json()
655
- return HealthResult(status=data["status"], version=data["version"])
658
+ return HealthResult(
659
+ status=data["status"],
660
+ version=data["version"],
661
+ relayer_version=data.get("relayerVersion"),
662
+ api_version=data.get("apiVersion"),
663
+ min_supported_sdk=data.get("minSupportedSdk"),
664
+ feature_flags=data.get("featureFlags"),
665
+ deprecations=data.get("deprecations"),
666
+ build=data.get("build"),
667
+ mode=data.get("mode"),
668
+ )
669
+
670
+ async def compatibility(self) -> Dict[str, Any]:
671
+ """Fetch and validate the relayer compatibility contract."""
672
+
673
+ return await self._ensure_compatible_relayer()
656
674
 
657
675
  # ============================================================
658
676
  # Manual API (user handles SEAL + embedding + Walrus)
@@ -727,6 +745,42 @@ class MemWal:
727
745
  # Internal: Signed HTTP Requests
728
746
  # ============================================================
729
747
 
748
+ async def _ensure_compatible_relayer(self) -> Dict[str, Any]:
749
+ if self._relayer_version_metadata is not None:
750
+ return self._relayer_version_metadata
751
+
752
+ if self._compatibility_lock is None:
753
+ self._compatibility_lock = asyncio.Lock()
754
+
755
+ async with self._compatibility_lock:
756
+ if self._relayer_version_metadata is not None:
757
+ return self._relayer_version_metadata
758
+
759
+ version_response = await self._http.get(f"{self._server_url}/version")
760
+ if version_response.status_code == 200:
761
+ metadata = version_response.json()
762
+ elif version_response.status_code in (404, 405):
763
+ health_response = await self._http.get(f"{self._server_url}/health")
764
+ if health_response.status_code != 200:
765
+ raise MemWalError(
766
+ "MemWal compatibility check failed: "
767
+ f"GET /version returned {version_response.status_code}, "
768
+ f"and GET /health returned {health_response.status_code}"
769
+ )
770
+ metadata = health_response.json()
771
+ else:
772
+ raise MemWalError(
773
+ "MemWal compatibility check failed: "
774
+ f"GET /version returned {version_response.status_code}"
775
+ )
776
+
777
+ error = compatibility_error(metadata, self._server_url)
778
+ if error is not None:
779
+ raise MemWalCompatibilityError(error)
780
+
781
+ self._relayer_version_metadata = metadata
782
+ return metadata
783
+
730
784
  async def _fetch_server_config(self) -> Dict[str, str]:
731
785
  if self._server_config is not None:
732
786
  return self._server_config
@@ -842,7 +896,7 @@ class MemWal:
842
896
  """Make a signed request to the server.
843
897
 
844
898
  Signature format:
845
- ``{timestamp}.{method}.{path}.{body_sha256}.{nonce}.{account_id}``
899
+ ``{timestamp}.{method}.{path_and_query}.{body_sha256}.{nonce}.{account_id}``
846
900
 
847
901
  For ``GET`` requests the canonical body string is the empty string,
848
902
  and no HTTP request body is sent. This keeps the signed payload hash
@@ -853,12 +907,15 @@ class MemWal:
853
907
  - ``x-public-key``: Ed25519 public key hex
854
908
  - ``x-signature``: Ed25519 signature hex
855
909
  - ``x-timestamp``: Unix seconds string
910
+ - ``x-nonce``: UUID v4 replay-protection nonce
856
911
  - ``x-seal-session``: Base64-encoded exported session envelope
857
912
  - ``x-account-id``: MemWalAccount object ID
858
913
  - ``Content-Type``: application/json
859
914
  """
860
915
  import uuid
861
916
 
917
+ await self._ensure_compatible_relayer()
918
+
862
919
  method_upper = method.upper()
863
920
  timestamp = str(int(time.time()))
864
921
  body_str = "" if method_upper == "GET" else json.dumps(body, separators=(",", ":"))
@@ -899,6 +956,12 @@ class MemWal:
899
956
 
900
957
  if response.status_code not in accepted_statuses:
901
958
  err_text = response.text
959
+ if response.status_code == 426:
960
+ raise MemWalCompatibilityError(
961
+ "MemWal relayer rejected this SDK as unsupported "
962
+ f"(HTTP 426 Upgrade Required). Relayer response: "
963
+ f"{err_text[:300] or 'upgrade required'}"
964
+ )
902
965
  raise _HttpStatusError(
903
966
  status=response.status_code,
904
967
  body=err_text,
@@ -913,6 +976,12 @@ class MemWalError(Exception):
913
976
  pass
914
977
 
915
978
 
979
+ class MemWalCompatibilityError(MemWalError):
980
+ """Raised when the SDK and relayer API contract are incompatible."""
981
+
982
+ pass
983
+
984
+
916
985
  class _HttpStatusError(MemWalError):
917
986
  """Internal: raised when an HTTP response status is not in ``accepted_statuses``.
918
987
 
@@ -1135,6 +1204,10 @@ class MemWalSync:
1135
1204
  """Synchronous version of :meth:`MemWal.health`."""
1136
1205
  return self._run(self._inner.health())
1137
1206
 
1207
+ def compatibility(self) -> Dict[str, Any]:
1208
+ """Synchronous version of :meth:`MemWal.compatibility`."""
1209
+ return self._run(self._inner.compatibility())
1210
+
1138
1211
  def remember_manual(self, opts: RememberManualOptions) -> RememberManualResult:
1139
1212
  """Synchronous version of :meth:`MemWal.remember_manual`."""
1140
1213
  return self._run(self._inner.remember_manual(opts))
@@ -0,0 +1,72 @@
1
+ """Relayer/API compatibility checks for the Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any, Dict, Optional, Tuple
7
+
8
+ MEMWAL_PYTHON_COMPATIBILITY_VERSION = "0.1.0"
9
+ SUPPORTED_RELAYER_API_MAJOR = 1
10
+
11
+
12
+ def compatibility_error(metadata: Dict[str, Any], server_url: str) -> Optional[str]:
13
+ """Return an actionable error string when relayer metadata is unsupported."""
14
+
15
+ api_version = metadata.get("apiVersion")
16
+ relayer_version = metadata.get("relayerVersion")
17
+ min_supported = metadata.get("minSupportedSdk")
18
+
19
+ if not api_version or not relayer_version or not isinstance(min_supported, dict):
20
+ return (
21
+ f"MemWal relayer at {server_url} does not expose compatibility metadata. "
22
+ "Upgrade the relayer to a version that serves GET /version, or use an older SDK."
23
+ )
24
+
25
+ api_major = _semver_major(str(api_version))
26
+ if api_major is None:
27
+ return f'MemWal relayer at {server_url} returned invalid apiVersion "{api_version}".'
28
+
29
+ if api_major != SUPPORTED_RELAYER_API_MAJOR:
30
+ return (
31
+ "This MemWal Python SDK supports relayer API "
32
+ f"{SUPPORTED_RELAYER_API_MAJOR}.x, but {server_url} reports apiVersion "
33
+ f"{api_version}. Upgrade or downgrade the SDK/relayer pair."
34
+ )
35
+
36
+ min_python = min_supported.get("python")
37
+ if not isinstance(min_python, str):
38
+ return f"MemWal relayer at {server_url} did not report minSupportedSdk.python."
39
+ if _parse_semver(min_python) is None:
40
+ return (
41
+ f'MemWal relayer at {server_url} returned invalid '
42
+ f'minSupportedSdk.python "{min_python}".'
43
+ )
44
+
45
+ if _compare_semver(MEMWAL_PYTHON_COMPATIBILITY_VERSION, min_python) < 0:
46
+ return (
47
+ f"MemWal relayer at {server_url} requires Python SDK >= {min_python}, "
48
+ f"but this package supports the {MEMWAL_PYTHON_COMPATIBILITY_VERSION} "
49
+ "compatibility baseline. Upgrade memwal or use an older compatible relayer."
50
+ )
51
+
52
+ return None
53
+
54
+
55
+ def _semver_major(version: str) -> Optional[int]:
56
+ parsed = _parse_semver(version)
57
+ return parsed[0] if parsed else None
58
+
59
+
60
+ def _compare_semver(left: str, right: str) -> int:
61
+ left_parts = _parse_semver(left)
62
+ right_parts = _parse_semver(right)
63
+ if left_parts is None or right_parts is None:
64
+ raise ValueError(f"invalid semver comparison: {left} vs {right}")
65
+ return (left_parts > right_parts) - (left_parts < right_parts)
66
+
67
+
68
+ def _parse_semver(version: str) -> Optional[Tuple[int, int, int]]:
69
+ match = re.match(r"^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$", version.strip())
70
+ if match is None:
71
+ return None
72
+ return int(match.group(1)), int(match.group(2)), int(match.group(3))
@@ -9,7 +9,7 @@ the MemWal Rust server (TEE).
9
9
  from __future__ import annotations
10
10
 
11
11
  from dataclasses import dataclass
12
- from typing import List, Optional
12
+ from typing import Any, Dict, List, Optional
13
13
 
14
14
  # ============================================================
15
15
  # Config
@@ -136,6 +136,13 @@ class HealthResult:
136
136
 
137
137
  status: str
138
138
  version: str
139
+ relayer_version: Optional[str] = None
140
+ api_version: Optional[str] = None
141
+ min_supported_sdk: Optional[Dict[str, str]] = None
142
+ feature_flags: Optional[Dict[str, bool]] = None
143
+ deprecations: Optional[List[Dict[str, Any]]] = None
144
+ build: Optional[Dict[str, Any]] = None
145
+ mode: Optional[str] = None
139
146
 
140
147
 
141
148
  @dataclass
@@ -105,7 +105,7 @@ def build_signature_message(
105
105
 
106
106
  Current format (matches Rust server ``services/server/src/auth.rs``)::
107
107
 
108
- "{timestamp}.{method}.{path}.{body_sha256}.{nonce}.{account_id}"
108
+ "{timestamp}.{method}.{path_and_query}.{body_sha256}.{nonce}.{account_id}"
109
109
 
110
110
  The trailing ``nonce`` was added in MED-1 (replay protection); the
111
111
  ``account_id`` was added in LOW-23 so an intermediary can't swap the
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memwal"
7
- version = "0.1.0.dev3"
7
+ version = "0.1.1.dev0"
8
8
  description = "Python SDK for MemWal — Privacy-first AI memory with Ed25519 signing"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -16,7 +16,7 @@ import nacl.signing
16
16
  import pytest
17
17
  import respx
18
18
 
19
- from memwal.client import MemWal, MemWalError
19
+ from memwal.client import MemWal, MemWalCompatibilityError, MemWalError
20
20
  from memwal.types import RecallManualOptions, RememberManualOptions
21
21
  from memwal.utils import build_signature_message, bytes_to_hex, sha256_hex
22
22
 
@@ -35,7 +35,35 @@ _TEST_PACKAGE_ID = "0x" + "11" * 32
35
35
  _TEST_SUI_RPC = "http://localhost:9001"
36
36
 
37
37
 
38
+ def _version_payload(
39
+ api_version: str = "1.0.0",
40
+ min_python: str = "0.1.0",
41
+ ) -> dict[str, Any]:
42
+ return {
43
+ "relayerVersion": "0.1.0",
44
+ "apiVersion": api_version,
45
+ "minSupportedSdk": {
46
+ "typescript": "0.0.4",
47
+ "python": min_python,
48
+ "mcp": "0.0.1",
49
+ },
50
+ "featureFlags": {"runtime.versionEndpoint": True},
51
+ "deprecations": [],
52
+ "build": {},
53
+ }
54
+
55
+
56
+ def _mock_version(
57
+ api_version: str = "1.0.0",
58
+ min_python: str = "0.1.0",
59
+ ) -> None:
60
+ respx.get(f"{_TEST_SERVER}/version").mock(
61
+ return_value=httpx.Response(200, json=_version_payload(api_version, min_python))
62
+ )
63
+
64
+
38
65
  def mock_seal_session_prereqs() -> None:
66
+ _mock_version()
39
67
  respx.get(f"{_TEST_SERVER}/config").mock(
40
68
  return_value=httpx.Response(
41
69
  200,
@@ -409,10 +437,58 @@ class TestHealth:
409
437
  assert result.status == "ok"
410
438
  assert result.version == "0.1.0"
411
439
 
440
+ @respx.mock
441
+ async def test_compatibility(self, memwal_client: MemWal) -> None:
442
+ _mock_version()
443
+
444
+ metadata = await memwal_client.compatibility()
445
+
446
+ assert metadata["apiVersion"] == "1.0.0"
447
+ assert metadata["minSupportedSdk"]["python"] == "0.1.0"
448
+
449
+ @respx.mock
450
+ async def test_compatibility_rejects_unsupported_relayer(
451
+ self, memwal_client: MemWal
452
+ ) -> None:
453
+ _mock_version(api_version="2.0.0")
454
+
455
+ with pytest.raises(MemWalCompatibilityError, match="supports relayer API 1.x"):
456
+ await memwal_client.compatibility()
457
+
458
+ @respx.mock
459
+ async def test_compatibility_rejects_old_sdk(self, memwal_client: MemWal) -> None:
460
+ _mock_version(min_python="9.0.0")
461
+
462
+ with pytest.raises(MemWalCompatibilityError, match="requires Python SDK >= 9.0.0"):
463
+ await memwal_client.recall("test")
464
+
465
+ @respx.mock
466
+ async def test_compatibility_rejects_missing_python_min(
467
+ self, memwal_client: MemWal
468
+ ) -> None:
469
+ payload = _version_payload()
470
+ del payload["minSupportedSdk"]["python"]
471
+ respx.get(f"{_TEST_SERVER}/version").mock(
472
+ return_value=httpx.Response(200, json=payload)
473
+ )
474
+
475
+ with pytest.raises(MemWalCompatibilityError, match="minSupportedSdk.python"):
476
+ await memwal_client.compatibility()
477
+
478
+ @respx.mock
479
+ async def test_compatibility_rejects_invalid_python_min(
480
+ self, memwal_client: MemWal
481
+ ) -> None:
482
+ _mock_version(min_python="latest")
483
+
484
+ with pytest.raises(MemWalCompatibilityError, match="invalid minSupportedSdk.python"):
485
+ await memwal_client.compatibility()
486
+
412
487
 
413
488
  class TestManualAPI:
414
489
  @respx.mock
415
490
  async def test_remember_manual(self, memwal_client: MemWal) -> None:
491
+ _mock_version()
416
492
  route = respx.post(f"{_TEST_SERVER}/api/remember/manual").mock(
417
493
  return_value=httpx.Response(
418
494
  200,
@@ -441,6 +517,7 @@ class TestManualAPI:
441
517
 
442
518
  @respx.mock
443
519
  async def test_recall_manual(self, memwal_client: MemWal) -> None:
520
+ _mock_version()
444
521
  route = respx.post(f"{_TEST_SERVER}/api/recall/manual").mock(
445
522
  return_value=httpx.Response(
446
523
  200,
@@ -47,7 +47,7 @@ import httpx
47
47
  import nacl.signing
48
48
  import pytest
49
49
 
50
- from memwal.client import MemWal, MemWalError, MemWalSync
50
+ from memwal.client import MemWal, MemWalCompatibilityError, MemWalError, MemWalSync
51
51
  from memwal.utils import build_signature_message, bytes_to_hex
52
52
 
53
53
  # ── Config ───────────────────────────────────────────────────────────────────
@@ -173,6 +173,8 @@ class TestAuthRejection:
173
173
  mw = MemWalSync.create(key=unregistered_key, account_id="0x0", server_url=SERVER_URL)
174
174
  with pytest.raises(MemWalError) as exc_info:
175
175
  mw.remember("hello")
176
+ if isinstance(exc_info.value, MemWalCompatibilityError):
177
+ pytest.skip("live relayer does not expose compatibility metadata yet")
176
178
  err = str(exc_info.value)
177
179
  assert "401" in err or "403" in err, f"Expected 401/403 in: {err}"
178
180
 
@@ -51,10 +51,31 @@ _SERVER = "http://localhost:8000"
51
51
 
52
52
  _RECALL_URL = f"{_SERVER}/api/recall"
53
53
  _ANALYZE_URL = f"{_SERVER}/api/analyze"
54
+ _VERSION_URL = f"{_SERVER}/version"
54
55
  _SUI_RPC_URL = "http://localhost:9001"
55
56
  _PACKAGE_ID = "0x" + "11" * 32
56
57
 
57
58
 
59
+ def _mock_version() -> None:
60
+ respx.get(_VERSION_URL).mock(
61
+ return_value=httpx.Response(
62
+ 200,
63
+ json={
64
+ "relayerVersion": "0.1.0",
65
+ "apiVersion": "1.0.0",
66
+ "minSupportedSdk": {
67
+ "typescript": "0.0.4",
68
+ "python": "0.1.0",
69
+ "mcp": "0.0.1",
70
+ },
71
+ "featureFlags": {"runtime.versionEndpoint": True},
72
+ "deprecations": [],
73
+ "build": {},
74
+ },
75
+ )
76
+ )
77
+
78
+
58
79
  def _mock_seal_session_prereqs() -> None:
59
80
  """Register mocks for the SEAL session prerequisites.
60
81
 
@@ -66,6 +87,7 @@ def _mock_seal_session_prereqs() -> None:
66
87
 
67
88
  Mirrors the helper of the same name in ``test_client.py``.
68
89
  """
90
+ _mock_version()
69
91
  respx.get(f"{_SERVER}/config").mock(
70
92
  return_value=httpx.Response(
71
93
  200,
File without changes
File without changes