memwal 0.1.9.dev1__tar.gz → 0.1.9.dev3__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.
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/CHANGELOG.md +2 -0
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/PKG-INFO +1 -1
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/memwal/__init__.py +1 -1
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/memwal/client.py +66 -2
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/pyproject.toml +1 -1
- memwal-0.1.9.dev3/tests/test_auth_rejected_message.py +30 -0
- memwal-0.1.9.dev3/tests/test_normalize_server_url.py +96 -0
- memwal-0.1.9.dev1/tests/test_auth_rejected_message.py +0 -9
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/.gitignore +0 -0
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/README.md +0 -0
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/examples/.env.example +0 -0
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/examples/.gitignore +0 -0
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/examples/async_remember_demo.py +0 -0
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/examples/interactive_demo.py +0 -0
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/examples/verify_credentials.py +0 -0
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/memwal/compatibility.py +0 -0
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/memwal/middleware.py +0 -0
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/memwal/mock.py +0 -0
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/memwal/types.py +0 -0
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/memwal/utils.py +0 -0
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/run_tests.py +0 -0
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/tests/__init__.py +0 -0
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/tests/test_client.py +0 -0
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/tests/test_env_presets.py +0 -0
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/tests/test_integration.py +0 -0
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/tests/test_middleware.py +0 -0
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/tests/test_mock.py +0 -0
- {memwal-0.1.9.dev1 → memwal-0.1.9.dev3}/tests/test_signing.py +0 -0
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
### Fixed
|
|
6
6
|
|
|
7
|
+
- HTTP 503 with `x-auth-error: AUTH_UPSTREAM_UNAVAILABLE` is reported as a retryable credential-verification outage, not a sign-in failure. Other 503s keep the generic sanitized body.
|
|
7
8
|
- `remember_bulk_async` rejects an empty `items` list before the request and raises when the relayer returns a `job_ids` length that does not match the batch.
|
|
8
9
|
- restore `truncated` docs now match WALM-431 retryable semantics.
|
|
10
|
+
- Warn when `server_url` uses plaintext `http://` against a non-localhost host, matching the TypeScript SDK `normalizeServerUrl` guard. Localhost, `127.0.0.1`, `::1`, and `*.localhost` are exempt; invalid URLs are left for the HTTP client to surface. The warning logs only scheme, host, and port so URL userinfo is not written to logs.
|
|
9
11
|
|
|
10
12
|
## 0.1.8
|
|
11
13
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: memwal
|
|
3
|
-
Version: 0.1.9.
|
|
3
|
+
Version: 0.1.9.dev3
|
|
4
4
|
Summary: Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing
|
|
5
5
|
Project-URL: Homepage, https://memory.walrus.xyz
|
|
6
6
|
Project-URL: Documentation, https://memory.walrus.xyz
|
|
@@ -35,6 +35,7 @@ import time
|
|
|
35
35
|
import uuid
|
|
36
36
|
from datetime import datetime, timezone
|
|
37
37
|
from typing import Any, Dict, List, Optional, Sequence, Tuple, TypeVar, Union
|
|
38
|
+
from urllib.parse import ParseResult, urlparse
|
|
38
39
|
|
|
39
40
|
import httpx
|
|
40
41
|
import nacl.signing
|
|
@@ -97,11 +98,62 @@ AUTH_REJECTED_MESSAGE = (
|
|
|
97
98
|
"and dashboard credentials. Full troubleshooting: "
|
|
98
99
|
"https://docs.wal.app/walrus-memory/troubleshooting/overview#401-auth_rejected-errors"
|
|
99
100
|
)
|
|
101
|
+
AUTH_UPSTREAM_UNAVAILABLE = "AUTH_UPSTREAM_UNAVAILABLE"
|
|
102
|
+
UPSTREAM_UNAVAILABLE_MESSAGE = (
|
|
103
|
+
"Walrus Memory temporarily cannot verify credentials (upstream unavailable). "
|
|
104
|
+
"Retry; this is not a sign-in failure."
|
|
105
|
+
)
|
|
100
106
|
|
|
101
107
|
|
|
102
108
|
logger = logging.getLogger("memwal")
|
|
103
109
|
|
|
104
110
|
|
|
111
|
+
def _server_url_for_log(parsed: ParseResult) -> str:
|
|
112
|
+
"""Scheme/host/port only — never userinfo, path, query, or fragment."""
|
|
113
|
+
|
|
114
|
+
host = parsed.hostname or ""
|
|
115
|
+
if ":" in host:
|
|
116
|
+
host = f"[{host}]"
|
|
117
|
+
if parsed.port is not None:
|
|
118
|
+
return f"{parsed.scheme}://{host}:{parsed.port}"
|
|
119
|
+
return f"{parsed.scheme}://{host}"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def normalize_server_url(url: str) -> str:
|
|
123
|
+
"""Strip a trailing slash and warn on plaintext HTTP to a remote host.
|
|
124
|
+
|
|
125
|
+
Ports the TypeScript ``normalizeServerUrl`` helper: localhost,
|
|
126
|
+
``127.0.0.1``, ``::1``, and ``*.localhost`` are exempt. Invalid URLs
|
|
127
|
+
are returned trimmed so the HTTP client can surface the error later.
|
|
128
|
+
|
|
129
|
+
The warning logs only scheme/host/port so URL userinfo (HTTPX
|
|
130
|
+
credentials) and other sensitive components are not written to logs.
|
|
131
|
+
The returned URL is otherwise unchanged and still used for transport.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
trimmed = url.rstrip("/")
|
|
135
|
+
try:
|
|
136
|
+
parsed = urlparse(trimmed)
|
|
137
|
+
host = (parsed.hostname or "").lower()
|
|
138
|
+
is_local = (
|
|
139
|
+
host == "localhost"
|
|
140
|
+
or host == "127.0.0.1"
|
|
141
|
+
or host == "::1"
|
|
142
|
+
or host.endswith(".localhost")
|
|
143
|
+
)
|
|
144
|
+
if parsed.scheme == "http" and host and not is_local:
|
|
145
|
+
logger.warning(
|
|
146
|
+
'[memwal] serverUrl "%s" uses plaintext HTTP on a non-localhost host. '
|
|
147
|
+
"Signed requests and any bearer material will be visible to the network. "
|
|
148
|
+
"Use https:// in production.",
|
|
149
|
+
_server_url_for_log(parsed),
|
|
150
|
+
)
|
|
151
|
+
except ValueError:
|
|
152
|
+
# invalid URL — let the HTTP call surface the error at request time
|
|
153
|
+
pass
|
|
154
|
+
return trimmed
|
|
155
|
+
|
|
156
|
+
|
|
105
157
|
# ============================================================
|
|
106
158
|
# Polling helpers (PR #121 parity with TS SDK)
|
|
107
159
|
# ============================================================
|
|
@@ -229,7 +281,7 @@ class MemWal:
|
|
|
229
281
|
self._private_key_hex = normalize_private_key(config.key)
|
|
230
282
|
self._signing_key = build_signing_key(self._private_key_hex)
|
|
231
283
|
self._account_id = config.account_id
|
|
232
|
-
self._server_url = config.server_url
|
|
284
|
+
self._server_url = normalize_server_url(config.server_url)
|
|
233
285
|
self._namespace = config.namespace
|
|
234
286
|
self._client: Optional[httpx.AsyncClient] = None
|
|
235
287
|
self._server_config: Optional[Dict[str, str]] = None
|
|
@@ -1285,6 +1337,8 @@ class MemWal:
|
|
|
1285
1337
|
raise _HttpStatusError(
|
|
1286
1338
|
status=response.status_code,
|
|
1287
1339
|
body=err_text,
|
|
1340
|
+
auth_error=response.headers.get("x-auth-error"),
|
|
1341
|
+
retry_after=response.headers.get("retry-after"),
|
|
1288
1342
|
)
|
|
1289
1343
|
|
|
1290
1344
|
return response.json()
|
|
@@ -1328,15 +1382,25 @@ class _HttpStatusError(MemWalError):
|
|
|
1328
1382
|
explicitly accepted).
|
|
1329
1383
|
"""
|
|
1330
1384
|
|
|
1331
|
-
def __init__(
|
|
1385
|
+
def __init__(
|
|
1386
|
+
self,
|
|
1387
|
+
status: int,
|
|
1388
|
+
body: str,
|
|
1389
|
+
auth_error: str | None = None,
|
|
1390
|
+
retry_after: str | None = None,
|
|
1391
|
+
) -> None:
|
|
1332
1392
|
if status == 401:
|
|
1333
1393
|
super().__init__(AUTH_REJECTED_MESSAGE)
|
|
1394
|
+
elif status == 503 and auth_error == AUTH_UPSTREAM_UNAVAILABLE:
|
|
1395
|
+
super().__init__(UPSTREAM_UNAVAILABLE_MESSAGE)
|
|
1334
1396
|
else:
|
|
1335
1397
|
super().__init__(
|
|
1336
1398
|
f"Walrus Memory API error ({status}): {_redact_internal_urls(body)}"
|
|
1337
1399
|
)
|
|
1338
1400
|
self.status = status
|
|
1339
1401
|
self.body = body
|
|
1402
|
+
self.auth_error = auth_error
|
|
1403
|
+
self.retry_after = retry_after
|
|
1340
1404
|
|
|
1341
1405
|
|
|
1342
1406
|
class MemWalRememberJobNotFound(MemWalError):
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Tests for the AUTH_REJECTED_MESSAGE shown on a 401 from the relayer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from memwal.client import (
|
|
6
|
+
AUTH_REJECTED_MESSAGE,
|
|
7
|
+
AUTH_UPSTREAM_UNAVAILABLE,
|
|
8
|
+
UPSTREAM_UNAVAILABLE_MESSAGE,
|
|
9
|
+
_HttpStatusError,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_auth_rejected_message_points_to_troubleshooting_guide() -> None:
|
|
14
|
+
assert "docs.wal.app/walrus-memory/troubleshooting/overview" in AUTH_REJECTED_MESSAGE
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_auth_503_is_retryable_not_a_credential_failure() -> None:
|
|
18
|
+
err = _HttpStatusError(
|
|
19
|
+
503, "upstream unavailable", auth_error=AUTH_UPSTREAM_UNAVAILABLE, retry_after="5"
|
|
20
|
+
)
|
|
21
|
+
assert str(err) == UPSTREAM_UNAVAILABLE_MESSAGE
|
|
22
|
+
assert "sign-in" in str(err)
|
|
23
|
+
assert "401" not in str(err)
|
|
24
|
+
assert err.retry_after == "5"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_non_auth_503_keeps_generic_body() -> None:
|
|
28
|
+
err = _HttpStatusError(503, "Rate limiter temporarily unavailable")
|
|
29
|
+
assert "Rate limiter temporarily unavailable" in str(err)
|
|
30
|
+
assert "cannot verify credentials" not in str(err)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Tests for plaintext HTTP server_url guarding (WALM-452 / #748).
|
|
2
|
+
|
|
3
|
+
No network: ``MemWal.create`` only stores config, and
|
|
4
|
+
``normalize_server_url`` is a pure parse + log helper.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from memwal.client import MemWal, normalize_server_url
|
|
14
|
+
|
|
15
|
+
_KEY = "ab" * 32
|
|
16
|
+
_ACCOUNT = "0xdummy"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_plaintext_remote_warns_and_strips(caplog: pytest.LogCaptureFixture) -> None:
|
|
20
|
+
caplog.set_level(logging.WARNING, logger="memwal")
|
|
21
|
+
assert (
|
|
22
|
+
normalize_server_url("http://relayer.example.com/")
|
|
23
|
+
== "http://relayer.example.com"
|
|
24
|
+
)
|
|
25
|
+
assert "plaintext" in caplog.text
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_https_remote_does_not_warn(caplog: pytest.LogCaptureFixture) -> None:
|
|
29
|
+
caplog.set_level(logging.WARNING, logger="memwal")
|
|
30
|
+
assert (
|
|
31
|
+
normalize_server_url("https://relayer.memory.walrus.xyz")
|
|
32
|
+
== "https://relayer.memory.walrus.xyz"
|
|
33
|
+
)
|
|
34
|
+
assert caplog.text == ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.mark.parametrize(
|
|
38
|
+
"url",
|
|
39
|
+
[
|
|
40
|
+
"http://localhost:8000",
|
|
41
|
+
"http://127.0.0.1:8000",
|
|
42
|
+
"http://foo.localhost",
|
|
43
|
+
"http://[::1]:8000",
|
|
44
|
+
],
|
|
45
|
+
)
|
|
46
|
+
def test_plaintext_local_does_not_warn(
|
|
47
|
+
url: str, caplog: pytest.LogCaptureFixture
|
|
48
|
+
) -> None:
|
|
49
|
+
caplog.set_level(logging.WARNING, logger="memwal")
|
|
50
|
+
assert normalize_server_url(url) == url
|
|
51
|
+
assert caplog.text == ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_create_warns_on_plaintext_remote(caplog: pytest.LogCaptureFixture) -> None:
|
|
55
|
+
caplog.set_level(logging.WARNING, logger="memwal")
|
|
56
|
+
client = MemWal.create(
|
|
57
|
+
key=_KEY,
|
|
58
|
+
account_id=_ACCOUNT,
|
|
59
|
+
server_url="http://relayer.example.com/",
|
|
60
|
+
)
|
|
61
|
+
assert client._server_url == "http://relayer.example.com"
|
|
62
|
+
assert "plaintext" in caplog.text
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_plaintext_remote_warning_omits_url_credentials(
|
|
66
|
+
caplog: pytest.LogCaptureFixture,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""HTTPX userinfo must stay on the transport URL and out of the warning."""
|
|
69
|
+
|
|
70
|
+
caplog.set_level(logging.WARNING, logger="memwal")
|
|
71
|
+
url = "http://alice:example-secret@relayer.example.com/?token=super-secret"
|
|
72
|
+
assert (
|
|
73
|
+
normalize_server_url(url)
|
|
74
|
+
== "http://alice:example-secret@relayer.example.com/?token=super-secret"
|
|
75
|
+
)
|
|
76
|
+
assert "plaintext" in caplog.text
|
|
77
|
+
assert "http://relayer.example.com" in caplog.text
|
|
78
|
+
assert "alice" not in caplog.text
|
|
79
|
+
assert "example-secret" not in caplog.text
|
|
80
|
+
assert "super-secret" not in caplog.text
|
|
81
|
+
for rec in caplog.records:
|
|
82
|
+
assert "example-secret" not in rec.getMessage()
|
|
83
|
+
assert "example-secret" not in str(rec.args)
|
|
84
|
+
assert "super-secret" not in rec.getMessage()
|
|
85
|
+
assert "super-secret" not in str(rec.args)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_create_preserves_url_credentials_and_redacts_warning(
|
|
89
|
+
caplog: pytest.LogCaptureFixture,
|
|
90
|
+
) -> None:
|
|
91
|
+
caplog.set_level(logging.WARNING, logger="memwal")
|
|
92
|
+
url = "http://alice:example-secret@relayer.example.com/"
|
|
93
|
+
client = MemWal.create(key=_KEY, account_id=_ACCOUNT, server_url=url)
|
|
94
|
+
assert client._server_url == "http://alice:example-secret@relayer.example.com"
|
|
95
|
+
assert "plaintext" in caplog.text
|
|
96
|
+
assert "example-secret" not in caplog.text
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
"""Tests for the AUTH_REJECTED_MESSAGE shown on a 401 from the relayer."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from memwal.client import AUTH_REJECTED_MESSAGE
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def test_auth_rejected_message_points_to_troubleshooting_guide() -> None:
|
|
9
|
-
assert "docs.wal.app/walrus-memory/troubleshooting/overview" in AUTH_REJECTED_MESSAGE
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|