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.
@@ -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