memydev-base-sdk 0.1.0__py3-none-any.whl
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.
- memybase/__init__.py +88 -0
- memybase/_client.py +233 -0
- memybase/_collection.py +411 -0
- memybase/_errors.py +232 -0
- memybase/_filter.py +86 -0
- memybase/_graphql.py +49 -0
- memybase/_helpers.py +35 -0
- memybase/_mapping.py +55 -0
- memybase/_realtime.py +295 -0
- memybase/_self.py +137 -0
- memybase/_sync/__init__.py +3 -0
- memybase/_sync/_client.py +219 -0
- memybase/_sync/_collection.py +383 -0
- memybase/_sync/_graphql.py +51 -0
- memybase/_sync/_helpers.py +37 -0
- memybase/_sync/_self.py +139 -0
- memybase/_sync/_transport.py +223 -0
- memybase/_transport.py +257 -0
- memybase/_types.py +91 -0
- memybase/py.typed +0 -0
- memybase/sync/__init__.py +41 -0
- memydev_base_sdk-0.1.0.dist-info/METADATA +130 -0
- memydev_base_sdk-0.1.0.dist-info/RECORD +24 -0
- memydev_base_sdk-0.1.0.dist-info/WHEEL +4 -0
memybase/_sync/_self.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# GENERATED by scripts/gen_sync.py from memybase/_self.py — DO NOT EDIT.
|
|
2
|
+
# Regenerate: python sdk/py/scripts/gen_sync.py (CI gate: gen_sync.py --check).
|
|
3
|
+
"""
|
|
4
|
+
@fileoverview Customer self-service operations on /api/self/*.
|
|
5
|
+
@module memybase._self
|
|
6
|
+
@description Mirrors the /api/self router 1:1: profile, openapi, projects CRUD + archive/restore,
|
|
7
|
+
databases CRUD, schema versions/activate, api-keys,
|
|
8
|
+
settings, plan, usage. All methods require tenant-level auth.
|
|
9
|
+
@created 2026-07-04
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
# Absolute import of the shared/pure module (same pattern as _collection.py / _transport.py): unasync
|
|
17
|
+
# generates memybase/_sync/_self.py, whose relative `from ._types` would resolve to a nonexistent
|
|
18
|
+
# memybase/_sync/_types.py — the absolute path makes both the async module and its sync twin bind the
|
|
19
|
+
# SAME single _types module.
|
|
20
|
+
from memybase._types import SchemaActivationJobSummary
|
|
21
|
+
|
|
22
|
+
from ._transport import Transport
|
|
23
|
+
|
|
24
|
+
__all__ = ["SelfService"]
|
|
25
|
+
|
|
26
|
+
_e = lambda s: quote(str(s), safe="") # noqa: E731
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SelfService:
|
|
30
|
+
"""Async handle for authenticated /api/self/* customer endpoints."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, transport: Transport) -> None:
|
|
33
|
+
self._t = transport
|
|
34
|
+
|
|
35
|
+
# ── Profile ──────────────────────────────────────────────────────────────
|
|
36
|
+
def profile(self) -> Any:
|
|
37
|
+
return self._t.request("GET", "/api/self/profile")
|
|
38
|
+
|
|
39
|
+
def openapi(self) -> Any:
|
|
40
|
+
return self._t.request("GET", "/api/self/openapi.json")
|
|
41
|
+
|
|
42
|
+
# ── Projects ─────────────────────────────────────────────────────────────
|
|
43
|
+
def list_projects(self) -> Any:
|
|
44
|
+
return self._t.request("GET", "/api/self/projects")
|
|
45
|
+
|
|
46
|
+
def create_project(self, body: dict[str, Any]) -> Any:
|
|
47
|
+
return self._t.request("POST", "/api/self/projects", json_body=body)
|
|
48
|
+
|
|
49
|
+
def get_project(self, ref: str) -> Any:
|
|
50
|
+
return self._t.request("GET", f"/api/self/projects/{_e(ref)}")
|
|
51
|
+
|
|
52
|
+
def update_project(self, ref: str, body: dict[str, Any]) -> Any:
|
|
53
|
+
return self._t.request("PATCH", f"/api/self/projects/{_e(ref)}", json_body=body)
|
|
54
|
+
|
|
55
|
+
def archive_project(self, ref: str) -> None:
|
|
56
|
+
self._t.request("POST", f"/api/self/projects/{_e(ref)}/archive")
|
|
57
|
+
|
|
58
|
+
def restore_project(self, ref: str) -> None:
|
|
59
|
+
self._t.request("POST", f"/api/self/projects/{_e(ref)}/restore")
|
|
60
|
+
|
|
61
|
+
def delete_project(self, ref: str) -> None:
|
|
62
|
+
self._t.request("DELETE", f"/api/self/projects/{_e(ref)}")
|
|
63
|
+
|
|
64
|
+
# ── Databases ────────────────────────────────────────────────────────────
|
|
65
|
+
def list_databases(self, project_ref: str) -> Any:
|
|
66
|
+
return self._t.request("GET", f"/api/self/projects/{_e(project_ref)}/databases")
|
|
67
|
+
|
|
68
|
+
def create_database(self, project_ref: str, body: dict[str, Any]) -> Any:
|
|
69
|
+
return self._t.request("POST", f"/api/self/projects/{_e(project_ref)}/databases", json_body=body)
|
|
70
|
+
|
|
71
|
+
def delete_database(self, project_ref: str, db: str) -> None:
|
|
72
|
+
self._t.request("DELETE", f"/api/self/projects/{_e(project_ref)}/databases/{_e(db)}")
|
|
73
|
+
|
|
74
|
+
def archive_database(self, project_ref: str, db: str) -> None:
|
|
75
|
+
self._t.request("POST", f"/api/self/projects/{_e(project_ref)}/databases/{_e(db)}/archive")
|
|
76
|
+
|
|
77
|
+
def restore_database(self, project_ref: str, db: str) -> None:
|
|
78
|
+
self._t.request("POST", f"/api/self/projects/{_e(project_ref)}/databases/{_e(db)}/restore")
|
|
79
|
+
|
|
80
|
+
# ── Schema ───────────────────────────────────────────────────────────────
|
|
81
|
+
def list_schema_versions(self, project_ref: str, db: str | None = None) -> Any:
|
|
82
|
+
base = f"/api/self/projects/{_e(project_ref)}"
|
|
83
|
+
path = f"{base}/databases/{_e(db)}/schema/versions" if db else f"{base}/schema/versions"
|
|
84
|
+
return self._t.request("GET", path)
|
|
85
|
+
|
|
86
|
+
def activate_schema(self, project_ref: str, draft: dict[str, Any], db: str | None = None) -> Any:
|
|
87
|
+
base = f"/api/self/projects/{_e(project_ref)}"
|
|
88
|
+
path = f"{base}/databases/{_e(db)}/schema/activate" if db else f"{base}/schema/activate"
|
|
89
|
+
return self._t.request("POST", path, json_body=draft)
|
|
90
|
+
|
|
91
|
+
def activate_schema_spec(self, project_ref: str, spec: dict[str, Any], db: str | None = None) -> Any:
|
|
92
|
+
base = f"/api/self/projects/{_e(project_ref)}"
|
|
93
|
+
path = f"{base}/databases/{_e(db)}/schema/design" if db else f"{base}/schema/design"
|
|
94
|
+
return self._t.request("POST", path, json_body=spec)
|
|
95
|
+
|
|
96
|
+
def get_schema_activation(
|
|
97
|
+
self, project_ref: str, job_id: str, db: str | None = None
|
|
98
|
+
) -> SchemaActivationJobSummary:
|
|
99
|
+
base = f"/api/self/projects/{_e(project_ref)}"
|
|
100
|
+
path = f"{base}/databases/{_e(db)}/schema/activations/{_e(job_id)}" if db else f"{base}/schema/activations/{_e(job_id)}"
|
|
101
|
+
return self._t.request("GET", path)
|
|
102
|
+
|
|
103
|
+
def rollback_schema_activation(
|
|
104
|
+
self, project_ref: str, job_id: str, db: str | None = None
|
|
105
|
+
) -> SchemaActivationJobSummary:
|
|
106
|
+
base = f"/api/self/projects/{_e(project_ref)}"
|
|
107
|
+
path = f"{base}/databases/{_e(db)}/schema/activations/{_e(job_id)}/rollback" if db else f"{base}/schema/activations/{_e(job_id)}/rollback"
|
|
108
|
+
return self._t.request("POST", path)
|
|
109
|
+
|
|
110
|
+
# ── Settings ─────────────────────────────────────────────────────────────
|
|
111
|
+
def get_settings(self, scope_type: str | None = None, scope_id: str | None = None) -> Any:
|
|
112
|
+
if scope_type and scope_id:
|
|
113
|
+
return self._t.request("GET", f"/api/self/settings/{_e(scope_type)}/{_e(scope_id)}")
|
|
114
|
+
return self._t.request("GET", "/api/self/settings")
|
|
115
|
+
|
|
116
|
+
def update_settings(self, values: dict[str, Any], scope_type: str | None = None, scope_id: str | None = None) -> Any:
|
|
117
|
+
if scope_type and scope_id:
|
|
118
|
+
return self._t.request("PUT", f"/api/self/settings/{_e(scope_type)}/{_e(scope_id)}", json_body=values)
|
|
119
|
+
return self._t.request("PUT", "/api/self/settings", json_body=values)
|
|
120
|
+
|
|
121
|
+
# ── API Keys ─────────────────────────────────────────────────────────────
|
|
122
|
+
def list_api_keys(self) -> Any:
|
|
123
|
+
return self._t.request("GET", "/api/self/api-keys")
|
|
124
|
+
|
|
125
|
+
def create_api_key(self, body: dict[str, Any]) -> Any:
|
|
126
|
+
return self._t.request("POST", "/api/self/api-keys", json_body=body)
|
|
127
|
+
|
|
128
|
+
def revoke_api_key(self, key_id: str) -> None:
|
|
129
|
+
self._t.request("DELETE", f"/api/self/api-keys/{_e(key_id)}")
|
|
130
|
+
|
|
131
|
+
def purge_api_key(self, key_id: str) -> None:
|
|
132
|
+
self._t.request("DELETE", f"/api/self/api-keys/{_e(key_id)}/purge")
|
|
133
|
+
|
|
134
|
+
# ── Plan & Usage ─────────────────────────────────────────────────────────
|
|
135
|
+
def get_plan(self) -> Any:
|
|
136
|
+
return self._t.request("GET", "/api/self/plan")
|
|
137
|
+
|
|
138
|
+
def get_usage(self) -> Any:
|
|
139
|
+
return self._t.request("GET", "/api/self/usage")
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# GENERATED by scripts/gen_sync.py from memybase/_transport.py — DO NOT EDIT.
|
|
2
|
+
# Regenerate: python sdk/py/scripts/gen_sync.py (CI gate: gen_sync.py --check).
|
|
3
|
+
"""
|
|
4
|
+
@fileoverview HTTP transport layer — auth injection, timeout, retry with backoff.
|
|
5
|
+
@module memybase._transport
|
|
6
|
+
@description Wraps httpx.AsyncClient with auth header injection (X-Api-Key > Bearer > X-User-Token),
|
|
7
|
+
per-request timeout, and bounded exponential backoff retry (reads: 408/429/503; writes:
|
|
8
|
+
429/503 only — never after bytes sent). Honors Retry-After header.
|
|
9
|
+
@created 2026-07-04
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
import math
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
from typing import Any, Iterator, Optional, TypedDict
|
|
17
|
+
from urllib.parse import urlencode, quote
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
# Absolute import of the shared/pure module: unasync generates memybase/_sync/_transport.py, whose
|
|
22
|
+
# relative `from ._errors` would resolve to a nonexistent memybase/_sync/_errors.py — the absolute path
|
|
23
|
+
# makes both the async module and its sync twin bind the SAME single _errors module.
|
|
24
|
+
from memybase._errors import (
|
|
25
|
+
MemyBaseError,
|
|
26
|
+
NetworkError,
|
|
27
|
+
TimeoutError,
|
|
28
|
+
decode_error,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = ["Transport", "RetryConfig"]
|
|
32
|
+
|
|
33
|
+
_READ_RETRY = frozenset({408, 429, 503})
|
|
34
|
+
_WRITE_RETRY = frozenset({429, 503})
|
|
35
|
+
_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "QUERY"})
|
|
36
|
+
|
|
37
|
+
_DEFAULT_MAX_RETRIES = 2
|
|
38
|
+
_DEFAULT_TIMEOUT = 30.0
|
|
39
|
+
_DEFAULT_BASE_DELAY = 0.5
|
|
40
|
+
_MAX_BACKOFF = 30.0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RetryConfig(TypedDict, total=False):
|
|
44
|
+
"""Retry policy matching the JS SDK's retry config, using Pythonic snake_case keys."""
|
|
45
|
+
|
|
46
|
+
max_retries: int
|
|
47
|
+
base_delay: float
|
|
48
|
+
retryable_statuses: list[int]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Transport:
|
|
52
|
+
"""Internal HTTP transport with auth, timeout, and retry."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
base_url: str,
|
|
57
|
+
*,
|
|
58
|
+
api_key: Optional[str] = None,
|
|
59
|
+
token: Optional[str] = None,
|
|
60
|
+
user_token: Optional[str] = None,
|
|
61
|
+
cookie: Optional[str] = None,
|
|
62
|
+
headers: Optional[dict[str, str]] = None,
|
|
63
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
64
|
+
max_retries: Optional[int] = None,
|
|
65
|
+
retry: Optional[RetryConfig] = None,
|
|
66
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self.base_url = base_url.rstrip("/")
|
|
69
|
+
self._api_key = api_key
|
|
70
|
+
self._token = token
|
|
71
|
+
self._user_token = user_token
|
|
72
|
+
self._cookie = cookie
|
|
73
|
+
self._extra_headers = dict(headers) if headers else {}
|
|
74
|
+
self._timeout = timeout
|
|
75
|
+
self._max_retries = _resolve_max_retries(retry, max_retries)
|
|
76
|
+
self._base_delay = _resolve_base_delay(retry)
|
|
77
|
+
self._extra_retryable_statuses = frozenset(int(s) for s in (retry or {}).get("retryable_statuses", []))
|
|
78
|
+
# Additive test seam: an injected httpx transport (e.g. httpx.MockTransport backed by an
|
|
79
|
+
# in-process fake engine) routes every request through the SAME real code paths
|
|
80
|
+
# (auth/serialize/retry/decode) with zero network. Untouched in prod (None → default transport).
|
|
81
|
+
self._transport = transport
|
|
82
|
+
self._client: Optional[httpx.Client] = None
|
|
83
|
+
|
|
84
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
85
|
+
# Precedence X-Api-Key > Bearer > X-User-Token, matching the JS SDK + contract.authHeaders. A
|
|
86
|
+
# session `cookie` is ADDITIVE (can coexist with a key) — the /api/self surface accepts lo_at
|
|
87
|
+
# cookie auth, so a cookie-only consumer can reach it.
|
|
88
|
+
h: dict[str, str] = {}
|
|
89
|
+
if self._api_key:
|
|
90
|
+
h["X-Api-Key"] = self._api_key
|
|
91
|
+
elif self._token:
|
|
92
|
+
h["Authorization"] = f"Bearer {self._token}"
|
|
93
|
+
elif self._user_token:
|
|
94
|
+
h["X-User-Token"] = self._user_token
|
|
95
|
+
if self._cookie:
|
|
96
|
+
h["Cookie"] = self._cookie
|
|
97
|
+
return h
|
|
98
|
+
|
|
99
|
+
def _get_client(self) -> httpx.Client:
|
|
100
|
+
if self._client is None or self._client.is_closed:
|
|
101
|
+
# Arbitrary extra headers are merged LAST so a caller can inject tracing/idempotency headers
|
|
102
|
+
# (and override the defaults if intended), mirroring JS's {Accept, ...auth, ...extraHeaders}.
|
|
103
|
+
kwargs: dict[str, Any] = {
|
|
104
|
+
"base_url": self.base_url,
|
|
105
|
+
"timeout": self._timeout,
|
|
106
|
+
"headers": {
|
|
107
|
+
"Accept": "application/json",
|
|
108
|
+
"User-Agent": "memybase-python/0.1.0",
|
|
109
|
+
**self._auth_headers(),
|
|
110
|
+
**self._extra_headers,
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
if self._transport is not None:
|
|
114
|
+
kwargs["transport"] = self._transport
|
|
115
|
+
self._client = httpx.Client(**kwargs)
|
|
116
|
+
return self._client
|
|
117
|
+
|
|
118
|
+
def request(
|
|
119
|
+
self,
|
|
120
|
+
method: str,
|
|
121
|
+
path: str,
|
|
122
|
+
*,
|
|
123
|
+
json_body: Any = None,
|
|
124
|
+
params: Optional[dict[str, str]] = None,
|
|
125
|
+
headers: Optional[dict[str, str]] = None,
|
|
126
|
+
text: bool = False,
|
|
127
|
+
) -> Any:
|
|
128
|
+
"""Send a request and return parsed JSON/text (or None for 204)."""
|
|
129
|
+
is_safe = method.upper() in _SAFE_METHODS
|
|
130
|
+
retryable_statuses = (_READ_RETRY | self._extra_retryable_statuses) if is_safe else _WRITE_RETRY
|
|
131
|
+
client = self._get_client()
|
|
132
|
+
attempt = 0
|
|
133
|
+
|
|
134
|
+
while True:
|
|
135
|
+
try:
|
|
136
|
+
resp = client.request(
|
|
137
|
+
method,
|
|
138
|
+
path,
|
|
139
|
+
json=json_body,
|
|
140
|
+
params=params,
|
|
141
|
+
headers=headers,
|
|
142
|
+
)
|
|
143
|
+
except httpx.TimeoutException as exc:
|
|
144
|
+
if attempt < self._max_retries and is_safe:
|
|
145
|
+
attempt += 1
|
|
146
|
+
time.sleep(_backoff(attempt, self._base_delay))
|
|
147
|
+
continue
|
|
148
|
+
raise TimeoutError(str(exc)) from exc
|
|
149
|
+
except httpx.HTTPError as exc:
|
|
150
|
+
raise NetworkError(str(exc)) from exc
|
|
151
|
+
|
|
152
|
+
if resp.status_code == 204:
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
if 200 <= resp.status_code < 300:
|
|
156
|
+
if text:
|
|
157
|
+
return resp.text
|
|
158
|
+
return resp.json() if resp.content else None
|
|
159
|
+
|
|
160
|
+
if resp.status_code in retryable_statuses and attempt < self._max_retries:
|
|
161
|
+
retry_after = _parse_retry_after(resp.headers.get("retry-after"))
|
|
162
|
+
delay = retry_after if retry_after is not None else _backoff(attempt + 1, self._base_delay)
|
|
163
|
+
attempt += 1
|
|
164
|
+
time.sleep(min(delay, _MAX_BACKOFF))
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
body = _safe_json(resp)
|
|
168
|
+
raise decode_error(
|
|
169
|
+
resp.status_code,
|
|
170
|
+
body=body,
|
|
171
|
+
retry_after_header=resp.headers.get("retry-after"),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def health(self, timeout: Optional[float] = None) -> bool:
|
|
175
|
+
"""Unauthenticated liveness probe → GET {base_url}/api/health (process-up; no Mongo, schema, or
|
|
176
|
+
key required). True on a 2xx, False on any non-OK / network error / timeout — a cheap, schema-
|
|
177
|
+
independent reachability signal for readiness gates, safe to call BEFORE a schema is provisioned."""
|
|
178
|
+
client = self._get_client()
|
|
179
|
+
try:
|
|
180
|
+
resp = client.request("GET", "/api/health", timeout=timeout or self._timeout)
|
|
181
|
+
except httpx.HTTPError:
|
|
182
|
+
return False
|
|
183
|
+
return 200 <= resp.status_code < 300
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def close(self) -> None:
|
|
187
|
+
if self._client is not None:
|
|
188
|
+
self._client.close()
|
|
189
|
+
self._client = None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _resolve_max_retries(retry: Optional[RetryConfig], legacy_max_retries: Optional[int]) -> int:
|
|
193
|
+
if retry and "max_retries" in retry:
|
|
194
|
+
return int(retry["max_retries"])
|
|
195
|
+
if legacy_max_retries is not None:
|
|
196
|
+
return int(legacy_max_retries)
|
|
197
|
+
return _DEFAULT_MAX_RETRIES
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _resolve_base_delay(retry: Optional[RetryConfig]) -> float:
|
|
201
|
+
if retry and "base_delay" in retry:
|
|
202
|
+
return float(retry["base_delay"])
|
|
203
|
+
return _DEFAULT_BASE_DELAY
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _backoff(attempt: int, base_delay: float = _DEFAULT_BASE_DELAY) -> float:
|
|
207
|
+
return min(_MAX_BACKOFF, base_delay * (2 ** (attempt - 1)))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _parse_retry_after(value: Optional[str]) -> Optional[float]:
|
|
211
|
+
if not value:
|
|
212
|
+
return None
|
|
213
|
+
try:
|
|
214
|
+
return max(0.0, float(value))
|
|
215
|
+
except ValueError:
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _safe_json(resp: httpx.Response) -> Any:
|
|
220
|
+
try:
|
|
221
|
+
return resp.json()
|
|
222
|
+
except Exception:
|
|
223
|
+
return None
|
memybase/_transport.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@fileoverview HTTP transport layer — auth injection, timeout, retry with backoff.
|
|
3
|
+
@module memybase._transport
|
|
4
|
+
@description Wraps httpx.AsyncClient with auth header injection (X-Api-Key > Bearer > X-User-Token),
|
|
5
|
+
per-request timeout, and bounded exponential backoff retry (reads: 408/429/503; writes:
|
|
6
|
+
429/503 only — never after bytes sent). Honors Retry-After header.
|
|
7
|
+
@created 2026-07-04
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import math
|
|
13
|
+
from contextlib import asynccontextmanager
|
|
14
|
+
from typing import Any, AsyncIterator, Optional, TypedDict
|
|
15
|
+
from urllib.parse import urlencode, quote
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
# Absolute import of the shared/pure module: unasync generates memybase/_sync/_transport.py, whose
|
|
20
|
+
# relative `from ._errors` would resolve to a nonexistent memybase/_sync/_errors.py — the absolute path
|
|
21
|
+
# makes both the async module and its sync twin bind the SAME single _errors module.
|
|
22
|
+
from memybase._errors import (
|
|
23
|
+
MemyBaseError,
|
|
24
|
+
NetworkError,
|
|
25
|
+
TimeoutError,
|
|
26
|
+
decode_error,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = ["Transport", "RetryConfig"]
|
|
30
|
+
|
|
31
|
+
_READ_RETRY = frozenset({408, 429, 503})
|
|
32
|
+
_WRITE_RETRY = frozenset({429, 503})
|
|
33
|
+
_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "QUERY"})
|
|
34
|
+
|
|
35
|
+
_DEFAULT_MAX_RETRIES = 2
|
|
36
|
+
_DEFAULT_TIMEOUT = 30.0
|
|
37
|
+
_DEFAULT_BASE_DELAY = 0.5
|
|
38
|
+
_MAX_BACKOFF = 30.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RetryConfig(TypedDict, total=False):
|
|
42
|
+
"""Retry policy matching the JS SDK's retry config, using Pythonic snake_case keys."""
|
|
43
|
+
|
|
44
|
+
max_retries: int
|
|
45
|
+
base_delay: float
|
|
46
|
+
retryable_statuses: list[int]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Transport:
|
|
50
|
+
"""Internal HTTP transport with auth, timeout, and retry."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
base_url: str,
|
|
55
|
+
*,
|
|
56
|
+
api_key: Optional[str] = None,
|
|
57
|
+
token: Optional[str] = None,
|
|
58
|
+
user_token: Optional[str] = None,
|
|
59
|
+
cookie: Optional[str] = None,
|
|
60
|
+
headers: Optional[dict[str, str]] = None,
|
|
61
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
62
|
+
max_retries: Optional[int] = None,
|
|
63
|
+
retry: Optional[RetryConfig] = None,
|
|
64
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
self.base_url = base_url.rstrip("/")
|
|
67
|
+
self._api_key = api_key
|
|
68
|
+
self._token = token
|
|
69
|
+
self._user_token = user_token
|
|
70
|
+
self._cookie = cookie
|
|
71
|
+
self._extra_headers = dict(headers) if headers else {}
|
|
72
|
+
self._timeout = timeout
|
|
73
|
+
self._max_retries = _resolve_max_retries(retry, max_retries)
|
|
74
|
+
self._base_delay = _resolve_base_delay(retry)
|
|
75
|
+
self._extra_retryable_statuses = frozenset(int(s) for s in (retry or {}).get("retryable_statuses", []))
|
|
76
|
+
# Additive test seam: an injected httpx transport (e.g. httpx.MockTransport backed by an
|
|
77
|
+
# in-process fake engine) routes every request through the SAME real code paths
|
|
78
|
+
# (auth/serialize/retry/decode) with zero network. Untouched in prod (None → default transport).
|
|
79
|
+
self._transport = transport
|
|
80
|
+
self._client: Optional[httpx.AsyncClient] = None
|
|
81
|
+
|
|
82
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
83
|
+
# Precedence X-Api-Key > Bearer > X-User-Token, matching the JS SDK + contract.authHeaders. A
|
|
84
|
+
# session `cookie` is ADDITIVE (can coexist with a key) — the /api/self surface accepts lo_at
|
|
85
|
+
# cookie auth, so a cookie-only consumer can reach it.
|
|
86
|
+
h: dict[str, str] = {}
|
|
87
|
+
if self._api_key:
|
|
88
|
+
h["X-Api-Key"] = self._api_key
|
|
89
|
+
elif self._token:
|
|
90
|
+
h["Authorization"] = f"Bearer {self._token}"
|
|
91
|
+
elif self._user_token:
|
|
92
|
+
h["X-User-Token"] = self._user_token
|
|
93
|
+
if self._cookie:
|
|
94
|
+
h["Cookie"] = self._cookie
|
|
95
|
+
return h
|
|
96
|
+
|
|
97
|
+
def _get_client(self) -> httpx.AsyncClient:
|
|
98
|
+
if self._client is None or self._client.is_closed:
|
|
99
|
+
# Arbitrary extra headers are merged LAST so a caller can inject tracing/idempotency headers
|
|
100
|
+
# (and override the defaults if intended), mirroring JS's {Accept, ...auth, ...extraHeaders}.
|
|
101
|
+
kwargs: dict[str, Any] = {
|
|
102
|
+
"base_url": self.base_url,
|
|
103
|
+
"timeout": self._timeout,
|
|
104
|
+
"headers": {
|
|
105
|
+
"Accept": "application/json",
|
|
106
|
+
"User-Agent": "memybase-python/0.1.0",
|
|
107
|
+
**self._auth_headers(),
|
|
108
|
+
**self._extra_headers,
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
if self._transport is not None:
|
|
112
|
+
kwargs["transport"] = self._transport
|
|
113
|
+
self._client = httpx.AsyncClient(**kwargs)
|
|
114
|
+
return self._client
|
|
115
|
+
|
|
116
|
+
async def request(
|
|
117
|
+
self,
|
|
118
|
+
method: str,
|
|
119
|
+
path: str,
|
|
120
|
+
*,
|
|
121
|
+
json_body: Any = None,
|
|
122
|
+
params: Optional[dict[str, str]] = None,
|
|
123
|
+
headers: Optional[dict[str, str]] = None,
|
|
124
|
+
text: bool = False,
|
|
125
|
+
) -> Any:
|
|
126
|
+
"""Send a request and return parsed JSON/text (or None for 204)."""
|
|
127
|
+
is_safe = method.upper() in _SAFE_METHODS
|
|
128
|
+
retryable_statuses = (_READ_RETRY | self._extra_retryable_statuses) if is_safe else _WRITE_RETRY
|
|
129
|
+
client = self._get_client()
|
|
130
|
+
attempt = 0
|
|
131
|
+
|
|
132
|
+
while True:
|
|
133
|
+
try:
|
|
134
|
+
resp = await client.request(
|
|
135
|
+
method,
|
|
136
|
+
path,
|
|
137
|
+
json=json_body,
|
|
138
|
+
params=params,
|
|
139
|
+
headers=headers,
|
|
140
|
+
)
|
|
141
|
+
except httpx.TimeoutException as exc:
|
|
142
|
+
if attempt < self._max_retries and is_safe:
|
|
143
|
+
attempt += 1
|
|
144
|
+
await asyncio.sleep(_backoff(attempt, self._base_delay))
|
|
145
|
+
continue
|
|
146
|
+
raise TimeoutError(str(exc)) from exc
|
|
147
|
+
except httpx.HTTPError as exc:
|
|
148
|
+
raise NetworkError(str(exc)) from exc
|
|
149
|
+
|
|
150
|
+
if resp.status_code == 204:
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
if 200 <= resp.status_code < 300:
|
|
154
|
+
if text:
|
|
155
|
+
return resp.text
|
|
156
|
+
return resp.json() if resp.content else None
|
|
157
|
+
|
|
158
|
+
if resp.status_code in retryable_statuses and attempt < self._max_retries:
|
|
159
|
+
retry_after = _parse_retry_after(resp.headers.get("retry-after"))
|
|
160
|
+
delay = retry_after if retry_after is not None else _backoff(attempt + 1, self._base_delay)
|
|
161
|
+
attempt += 1
|
|
162
|
+
await asyncio.sleep(min(delay, _MAX_BACKOFF))
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
body = _safe_json(resp)
|
|
166
|
+
raise decode_error(
|
|
167
|
+
resp.status_code,
|
|
168
|
+
body=body,
|
|
169
|
+
retry_after_header=resp.headers.get("retry-after"),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
async def health(self, timeout: Optional[float] = None) -> bool:
|
|
173
|
+
"""Unauthenticated liveness probe → GET {base_url}/api/health (process-up; no Mongo, schema, or
|
|
174
|
+
key required). True on a 2xx, False on any non-OK / network error / timeout — a cheap, schema-
|
|
175
|
+
independent reachability signal for readiness gates, safe to call BEFORE a schema is provisioned."""
|
|
176
|
+
client = self._get_client()
|
|
177
|
+
try:
|
|
178
|
+
resp = await client.request("GET", "/api/health", timeout=timeout or self._timeout)
|
|
179
|
+
except httpx.HTTPError:
|
|
180
|
+
return False
|
|
181
|
+
return 200 <= resp.status_code < 300
|
|
182
|
+
|
|
183
|
+
# <sync:exclude> realtime SSE (open_sse) is async-only — see _realtime.py; gen_sync.py drops this.
|
|
184
|
+
@asynccontextmanager
|
|
185
|
+
async def open_sse(
|
|
186
|
+
self,
|
|
187
|
+
path: str,
|
|
188
|
+
*,
|
|
189
|
+
last_event_id: Optional[str] = None,
|
|
190
|
+
) -> AsyncIterator[httpx.Response]:
|
|
191
|
+
"""Open a Server-Sent-Events GET stream as an async context manager.
|
|
192
|
+
|
|
193
|
+
Sets Accept: text/event-stream (+ Last-Event-ID for resume), decodes a >=400 body into the
|
|
194
|
+
proper MemyBaseError (so realtime failures surface the same typed exceptions as regular
|
|
195
|
+
requests), and wraps httpx transport failures into TimeoutError / NetworkError. The caller
|
|
196
|
+
iterates ``resp.aiter_lines()`` inside the ``async with`` — the stream (and connection) is
|
|
197
|
+
released on exit. Supersedes the removed ``request_stream`` (which never set SSE headers and
|
|
198
|
+
detached the stream context manager, leaking the connection).
|
|
199
|
+
"""
|
|
200
|
+
client = self._get_client()
|
|
201
|
+
headers = {"Accept": "text/event-stream"}
|
|
202
|
+
if last_event_id:
|
|
203
|
+
headers["Last-Event-ID"] = last_event_id
|
|
204
|
+
try:
|
|
205
|
+
async with client.stream("GET", path, headers=headers) as resp:
|
|
206
|
+
if resp.status_code >= 400:
|
|
207
|
+
await resp.aread()
|
|
208
|
+
raise decode_error(
|
|
209
|
+
resp.status_code,
|
|
210
|
+
body=_safe_json(resp),
|
|
211
|
+
retry_after_header=resp.headers.get("retry-after"),
|
|
212
|
+
)
|
|
213
|
+
yield resp
|
|
214
|
+
except httpx.TimeoutException as exc:
|
|
215
|
+
raise TimeoutError(str(exc)) from exc
|
|
216
|
+
except httpx.HTTPError as exc:
|
|
217
|
+
raise NetworkError(str(exc)) from exc
|
|
218
|
+
# </sync:exclude>
|
|
219
|
+
|
|
220
|
+
async def close(self) -> None:
|
|
221
|
+
if self._client is not None:
|
|
222
|
+
await self._client.aclose()
|
|
223
|
+
self._client = None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _resolve_max_retries(retry: Optional[RetryConfig], legacy_max_retries: Optional[int]) -> int:
|
|
227
|
+
if retry and "max_retries" in retry:
|
|
228
|
+
return int(retry["max_retries"])
|
|
229
|
+
if legacy_max_retries is not None:
|
|
230
|
+
return int(legacy_max_retries)
|
|
231
|
+
return _DEFAULT_MAX_RETRIES
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _resolve_base_delay(retry: Optional[RetryConfig]) -> float:
|
|
235
|
+
if retry and "base_delay" in retry:
|
|
236
|
+
return float(retry["base_delay"])
|
|
237
|
+
return _DEFAULT_BASE_DELAY
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _backoff(attempt: int, base_delay: float = _DEFAULT_BASE_DELAY) -> float:
|
|
241
|
+
return min(_MAX_BACKOFF, base_delay * (2 ** (attempt - 1)))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _parse_retry_after(value: Optional[str]) -> Optional[float]:
|
|
245
|
+
if not value:
|
|
246
|
+
return None
|
|
247
|
+
try:
|
|
248
|
+
return max(0.0, float(value))
|
|
249
|
+
except ValueError:
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _safe_json(resp: httpx.Response) -> Any:
|
|
254
|
+
try:
|
|
255
|
+
return resp.json()
|
|
256
|
+
except Exception:
|
|
257
|
+
return None
|