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/_filter.py ADDED
@@ -0,0 +1,86 @@
1
+ """
2
+ @fileoverview Fluent filter builder for MemyBase list queries.
3
+ @module memybase._filter
4
+ @description Supports the 14 operators (eq ne in nin gt gte lt lte contains startsWith istartsWith
5
+ endsWith exists between) matching the engine's filter contract (query-builder.ts
6
+ FILTER_OPERATORS). Produces the [{field,op,value}] JSON array the server expects.
7
+ @created 2026-07-04
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from typing import Any
13
+
14
+ __all__ = ["FilterBuilder", "OPERATORS", "serialize_filter"]
15
+
16
+ OPERATORS: frozenset[str] = frozenset({
17
+ "eq", "ne", "in", "nin", "gt", "gte", "lt", "lte",
18
+ "contains", "startsWith", "istartsWith", "endsWith", "exists", "between",
19
+ })
20
+
21
+
22
+ class FilterBuilder:
23
+ """Fluent builder that accumulates [{field, op, value}] conditions."""
24
+
25
+ def __init__(self) -> None:
26
+ self._conditions: list[dict[str, Any]] = []
27
+
28
+ def where(self, field: str, op: str, value: Any = None) -> "FilterBuilder":
29
+ if op not in OPERATORS:
30
+ raise ValueError(f"Unknown operator '{op}'; valid: {sorted(OPERATORS)}")
31
+ # ALWAYS serialize `value` (mirror JS filter.ts) — including a null operand. The prior
32
+ # `if value is not None` guard dropped it, so an is-null filter (e.g. eq('archivedAt', None))
33
+ # emitted [{field,op}]; the server's assertScalar(undefined) then 400s. Sending value:null lets
34
+ # the query-builder compile {$eq: null} and match.
35
+ self._conditions.append({"field": field, "op": op, "value": value})
36
+ return self
37
+
38
+ def eq(self, field: str, value: Any) -> "FilterBuilder":
39
+ return self.where(field, "eq", value)
40
+
41
+ def ne(self, field: str, value: Any) -> "FilterBuilder":
42
+ return self.where(field, "ne", value)
43
+
44
+ def gt(self, field: str, value: Any) -> "FilterBuilder":
45
+ return self.where(field, "gt", value)
46
+
47
+ def gte(self, field: str, value: Any) -> "FilterBuilder":
48
+ return self.where(field, "gte", value)
49
+
50
+ def lt(self, field: str, value: Any) -> "FilterBuilder":
51
+ return self.where(field, "lt", value)
52
+
53
+ def lte(self, field: str, value: Any) -> "FilterBuilder":
54
+ return self.where(field, "lte", value)
55
+
56
+ def in_(self, field: str, values: list[Any]) -> "FilterBuilder":
57
+ return self.where(field, "in", values)
58
+
59
+ def nin(self, field: str, values: list[Any]) -> "FilterBuilder":
60
+ return self.where(field, "nin", values)
61
+
62
+ def contains(self, field: str, value: str) -> "FilterBuilder":
63
+ return self.where(field, "contains", value)
64
+
65
+ def starts_with(self, field: str, value: str) -> "FilterBuilder":
66
+ return self.where(field, "startsWith", value)
67
+
68
+ def istarts_with(self, field: str, value: str) -> "FilterBuilder":
69
+ """Case-INSENSITIVE anchored prefix (server `istartsWith` — {$regex:^…, $options:'i'})."""
70
+ return self.where(field, "istartsWith", value)
71
+
72
+ def ends_with(self, field: str, value: str) -> "FilterBuilder":
73
+ return self.where(field, "endsWith", value)
74
+
75
+ def exists(self, field: str, value: bool = True) -> "FilterBuilder":
76
+ return self.where(field, "exists", value)
77
+
78
+ def between(self, field: str, low: Any, high: Any) -> "FilterBuilder":
79
+ return self.where(field, "between", [low, high])
80
+
81
+ def build(self) -> list[dict[str, Any]]:
82
+ return list(self._conditions)
83
+
84
+
85
+ def serialize_filter(conditions: list[dict[str, Any]]) -> str:
86
+ return json.dumps(conditions, separators=(",", ":"))
memybase/_graphql.py ADDED
@@ -0,0 +1,49 @@
1
+ """
2
+ @fileoverview GraphQL read-only client for per-project generated schemas.
3
+ @module memybase._graphql
4
+ @description Sends GraphQL queries against the per-project /api/v1 graphql endpoint and reads the
5
+ generated schema.sdl artifact as text. Read-only (no mutations/subscriptions). API keys
6
+ work. Mirrors the JS SDK's GraphQLClient.
7
+ @created 2026-07-04
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Optional
12
+ from urllib.parse import quote
13
+
14
+ from ._transport import Transport
15
+
16
+ __all__ = ["GraphQLClient"]
17
+
18
+
19
+ class GraphQLClient:
20
+ """Async GraphQL client for a specific project (optional database)."""
21
+
22
+ def __init__(
23
+ self,
24
+ transport: Transport,
25
+ *,
26
+ project: str,
27
+ database: Optional[str] = None,
28
+ ) -> None:
29
+ self._transport = transport
30
+ if database:
31
+ self._path = f"/api/v1/p/{quote(project, safe='')}/d/{quote(database, safe='')}/graphql"
32
+ else:
33
+ self._path = f"/api/v1/projects/{quote(project, safe='')}/graphql"
34
+
35
+ async def query(self, document: str, variables: Optional[dict[str, Any]] = None) -> Any:
36
+ """Execute a GraphQL query and return the response body ({data, errors?})."""
37
+ body: dict[str, Any] = {"query": document}
38
+ if variables:
39
+ body["variables"] = variables
40
+ return await self._transport.request("POST", self._path, json_body=body)
41
+
42
+ async def schema_sdl(self) -> str:
43
+ """GET {graphql_path}/schema.sdl as application/graphql text."""
44
+ return await self._transport.request(
45
+ "GET",
46
+ f"{self._path}/schema.sdl",
47
+ headers={"Accept": "application/graphql"},
48
+ text=True,
49
+ )
memybase/_helpers.py ADDED
@@ -0,0 +1,35 @@
1
+ """
2
+ @fileoverview Conflict retry helper for expected-version race conditions.
3
+ @module memybase._helpers
4
+ @description MemyBase writes accept expected_version / If-Match preconditions; this helper retries on
5
+ ConflictError with linear backoff, mirroring the JS SDK's withConflictRetry().
6
+ @created 2026-07-04
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from typing import Any, Awaitable, Callable, TypeVar
12
+
13
+ from memybase._errors import ConflictError # absolute: shared module, no _sync twin (see gen_sync.py)
14
+
15
+ __all__ = ["with_conflict_retry"]
16
+
17
+ T = TypeVar("T")
18
+
19
+
20
+ async def with_conflict_retry(
21
+ fn: Callable[[], Awaitable[T]],
22
+ *,
23
+ retries: int = 3,
24
+ delay: float = 0.2,
25
+ ) -> T:
26
+ """Retry fn() on ConflictError up to `retries` times with linear backoff."""
27
+ last_error: ConflictError | None = None
28
+ for attempt in range(retries + 1):
29
+ try:
30
+ return await fn()
31
+ except ConflictError as e:
32
+ last_error = e
33
+ if attempt < retries:
34
+ await asyncio.sleep(delay * (attempt + 1))
35
+ raise last_error # type: ignore[misc]
memybase/_mapping.py ADDED
@@ -0,0 +1,55 @@
1
+ """
2
+ @fileoverview Reserved-field guard and payload utilities.
3
+ @module memybase._mapping
4
+ @description Guards write payloads against the 15 non-writable engine-reserved system fields while
5
+ allowing the schema-reserved memyTtl exception. Strips
6
+ undefined/None keys from outbound payloads (null-omit rule). Mirrors the JS SDK's
7
+ mapping.ts and MemySwarm's mapping.py.
8
+ @created 2026-07-04
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Mapping
13
+
14
+ from ._errors import ReservedFieldError
15
+
16
+ __all__ = [
17
+ "CUSTOMER_WRITABLE_RESERVED_FIELDS",
18
+ "RESERVED_FIELDS",
19
+ "SCHEMA_RESERVED_FIELDS",
20
+ "guard_reserved_fields",
21
+ "omit_none",
22
+ ]
23
+
24
+ RESERVED_FIELDS: frozenset[str] = frozenset({
25
+ "_id",
26
+ "tenantId",
27
+ "projectId",
28
+ "entityId",
29
+ "schemaVersionId",
30
+ "createdAt",
31
+ "createdBy",
32
+ "updatedAt",
33
+ "updatedBy",
34
+ "deletedAt",
35
+ "deletedBy",
36
+ "deleteReason",
37
+ "version",
38
+ "_metadata",
39
+ "_system",
40
+ })
41
+
42
+ SCHEMA_RESERVED_FIELDS: frozenset[str] = RESERVED_FIELDS | {"memyTtl"}
43
+ CUSTOMER_WRITABLE_RESERVED_FIELDS: frozenset[str] = frozenset({"memyTtl"})
44
+
45
+
46
+ def guard_reserved_fields(body: Mapping[str, Any]) -> None:
47
+ """Raise ReservedFieldError if body contains any reserved key."""
48
+ violations = RESERVED_FIELDS.intersection(body.keys())
49
+ if violations:
50
+ raise ReservedFieldError(sorted(violations))
51
+
52
+
53
+ def omit_none(body: Mapping[str, Any]) -> dict[str, Any]:
54
+ """Return a copy of body with None-valued keys removed (null-omit rule)."""
55
+ return {k: v for k, v in body.items() if v is not None}
memybase/_realtime.py ADDED
@@ -0,0 +1,295 @@
1
+ """
2
+ @fileoverview SSE realtime subscription — ticket-based streaming with auto-reconnect.
3
+ @module memybase._realtime
4
+ @description Python port of the JS SDK's realtime.ts. Two-step flow: POST .../streams/tickets →
5
+ GET .../stream?ticket=. Parses SSE frames over an httpx streaming response, auto-
6
+ reconnects with bounded exponential backoff, re-mints the ticket on each attempt, tracks
7
+ Last-Event-ID for resume, and invokes on_resync when the server requests a re-snapshot.
8
+ ASYNC-ONLY: SSE is inherently async and the sync client (memybase.sync) targets scripts /
9
+ provisioning tooling with no event loop — realtime is deliberately not exposed there
10
+ (see sdk/py/tests/test_sync_parity.py's documented exclusion), mirroring the JS SDK,
11
+ which likewise ships realtime only on its single (async) client.
12
+ @dependencies ._transport (open_sse), httpx (transitively)
13
+ @relatedFiles ./_collection.py (Collection.subscribe), ./_client.py (MemyBase.stream),
14
+ ../../../js/src/realtime.ts (the reference implementation this mirrors 1:1)
15
+ @created 2026-07-05
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import contextlib
21
+ import json
22
+ from dataclasses import dataclass
23
+ from typing import Any, Callable, Optional
24
+ from urllib.parse import quote, urlsplit
25
+
26
+ from ._transport import Transport
27
+
28
+ __all__ = ["RealtimeClient", "StreamEvent", "SubscribeOptions", "Subscription"]
29
+
30
+ # Backoff parity with realtime.ts: delay = min(1000 * 2^(n-1), 30_000) ms → seconds here.
31
+ _RECONNECT_BASE = 1.0
32
+ _RECONNECT_MAX = 30.0
33
+ # Floor applied to EVERY reconnect delay — including a CLEAN stream end (EOF, no exception). A stream that
34
+ # opens then immediately EOFs (LB idle-timeout, draining backend, crash-loop) would otherwise re-mint a
35
+ # ticket + reconnect with zero delay, an RTT-bound hot loop hammering /streams/tickets. The floor bounds it.
36
+ _RECONNECT_MIN = 0.5
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class StreamEvent:
41
+ """A realtime event — the faithful Python mirror of the JS StreamEvent discriminated union:
42
+
43
+ - event='ready' data = {mode, entity, heartbeatMs}
44
+ - event='change' data = {type, entity, id, op?, doc?} (id = SSE id or None)
45
+ - event='heartbeat' data = <epoch-ms int>
46
+ - event='resync' data = {reason}
47
+ """
48
+
49
+ event: str
50
+ data: Any
51
+ id: Optional[str] = None
52
+
53
+
54
+ @dataclass
55
+ class SubscribeOptions:
56
+ """Subscription options — 1:1 with the JS SubscribeOptions {entity, lastEventId, onResync, signal}.
57
+
58
+ signal maps the JS AbortSignal to the idiomatic Python cancellation token (asyncio.Event): set it
59
+ to abort the subscription from outside. The primary stop path is ``await subscription.close()``.
60
+ """
61
+
62
+ entity: Optional[str] = None
63
+ last_event_id: Optional[str] = None
64
+ on_resync: Optional[Callable[[str], None]] = None
65
+ signal: Optional[asyncio.Event] = None
66
+
67
+
68
+ EventCallback = Callable[["StreamEvent"], None]
69
+
70
+
71
+ class Subscription:
72
+ """Handle to a live subscription.
73
+
74
+ ``await subscription.close()`` cancels AND drains the background task (a deliberate divergence
75
+ from the JS ``close(): void`` so resources tear down cleanly). ``closed`` reflects state; ``wait()``
76
+ awaits the background task until it stops. Also an async context manager.
77
+ """
78
+
79
+ def __init__(self) -> None:
80
+ self._closed = False
81
+ self._task: Optional[asyncio.Task[None]] = None
82
+ self._watch_task: Optional[asyncio.Task[None]] = None
83
+ self._last_event_id: Optional[str] = None
84
+
85
+ @property
86
+ def closed(self) -> bool:
87
+ return self._closed
88
+
89
+ async def close(self) -> None:
90
+ self._closed = True
91
+ for task in (self._task, self._watch_task):
92
+ if task is not None:
93
+ task.cancel()
94
+ with contextlib.suppress(asyncio.CancelledError):
95
+ await task
96
+
97
+ async def wait(self) -> None:
98
+ """Await the background reconnect task until the subscription stops (closed / terminal)."""
99
+ if self._task is not None:
100
+ with contextlib.suppress(asyncio.CancelledError):
101
+ await self._task
102
+
103
+ async def __aenter__(self) -> "Subscription":
104
+ return self
105
+
106
+ async def __aexit__(self, *exc: Any) -> None:
107
+ await self.close()
108
+
109
+
110
+ def _resync_reason(data: Any) -> str:
111
+ """Extract a resync reason robustly, mirroring realtime.ts `parsed.reason ?? 'unknown'`. A non-object
112
+ payload (a bare scalar) or a null/missing reason both collapse to 'unknown' — never an AttributeError
113
+ that would drop the whole event (the prior `data.get(...)` raised on a scalar payload)."""
114
+ reason = data.get("reason") if isinstance(data, dict) else None
115
+ return reason if isinstance(reason, str) else "unknown"
116
+
117
+
118
+ def _stream_path(stream: str) -> str:
119
+ """Mirror realtime.ts: an absolute '/...' path is used verbatim; a full URL → path+query only."""
120
+ if stream.startswith("/"):
121
+ return stream
122
+ parts = urlsplit(stream)
123
+ return parts.path + (f"?{parts.query}" if parts.query else "")
124
+
125
+
126
+ class RealtimeClient:
127
+ """Async SSE realtime client. Obtain subscriptions via ``MemyBase.stream`` or
128
+ ``Collection.subscribe``; direct use is also supported."""
129
+
130
+ def __init__(self, transport: Transport) -> None:
131
+ self._transport = transport
132
+
133
+ async def subscribe(
134
+ self,
135
+ project: str,
136
+ database: Optional[str],
137
+ on_event: EventCallback,
138
+ opts: Optional[SubscribeOptions] = None,
139
+ ) -> Subscription:
140
+ """Start a subscription; returns immediately with a live Subscription (the reconnect loop runs
141
+ in a background task, mirroring realtime.ts firing ``connect()`` without awaiting)."""
142
+ opts = opts or SubscribeOptions()
143
+
144
+ if database:
145
+ ticket_path = (
146
+ f"/api/v1/p/{quote(project, safe='')}/d/{quote(database, safe='')}/streams/tickets"
147
+ )
148
+ else:
149
+ ticket_path = f"/api/v1/projects/{quote(project, safe='')}/streams/tickets"
150
+
151
+ sub = Subscription()
152
+ sub._last_event_id = opts.last_event_id
153
+ sub._task = asyncio.create_task(self._run(ticket_path, on_event, opts, sub))
154
+
155
+ if opts.signal is not None:
156
+ async def _watch() -> None:
157
+ with contextlib.suppress(asyncio.CancelledError):
158
+ await opts.signal.wait() # type: ignore[union-attr]
159
+ await sub.close()
160
+
161
+ sub._watch_task = asyncio.create_task(_watch())
162
+
163
+ return sub
164
+
165
+ async def _run(
166
+ self,
167
+ ticket_path: str,
168
+ on_event: EventCallback,
169
+ opts: SubscribeOptions,
170
+ sub: Subscription,
171
+ ) -> None:
172
+ # Reconnect loop mirroring realtime.ts connect(): re-mint the ticket every iteration, reset the
173
+ # backoff after the stream opens, preserve Last-Event-ID across reconnects. Reconnects on ANY
174
+ # error (including 401/403/404) exactly like the JS SDK — see the module note.
175
+ reconnect_attempt = 0
176
+ while not sub._closed:
177
+ try:
178
+ # Yield each iteration so a tight clean-reconnect loop (a server that closes the stream
179
+ # immediately) can never starve concurrent tasks, and so cancellation from close() is
180
+ # always observed promptly. A no-op on the healthy path — a live SSE stream blocks in
181
+ # aiter_lines, so this only fires between (re)connections.
182
+ await asyncio.sleep(0)
183
+ ticket_body: dict[str, Any] = {}
184
+ if opts.entity:
185
+ ticket_body["entity"] = opts.entity
186
+ ticket = await self._transport.request(
187
+ "POST", ticket_path, json_body=ticket_body or None
188
+ )
189
+ stream_path = _stream_path(ticket["stream"])
190
+ async with self._transport.open_sse(
191
+ stream_path, last_event_id=sub._last_event_id
192
+ ) as resp:
193
+ reconnect_attempt = 0
194
+ await self._consume(resp, on_event, opts, sub)
195
+ # Reached here ⇒ the stream ended CLEANLY (EOF, no exception). This is still a reconnect,
196
+ # so it gets a FLOOR backoff (rank 34) — otherwise a fast-closing stream re-mints with no
197
+ # delay. Route it through the same throttle as the error path.
198
+ if sub._closed:
199
+ return
200
+ reconnect_attempt += 1
201
+ await self._backoff_sleep(reconnect_attempt)
202
+ except asyncio.CancelledError:
203
+ return
204
+ except Exception:
205
+ if sub._closed:
206
+ return
207
+ reconnect_attempt += 1
208
+ await self._backoff_sleep(reconnect_attempt)
209
+
210
+ async def _backoff_sleep(self, attempt: int) -> None:
211
+ """Sleep the reconnect backoff for ``attempt``, never below ``_RECONNECT_MIN`` (so even a clean
212
+ fast-close is throttled). Returns quietly on cancellation from close()."""
213
+ delay = min(_RECONNECT_BASE * (2 ** (attempt - 1)), _RECONNECT_MAX)
214
+ delay = max(delay, _RECONNECT_MIN)
215
+ try:
216
+ await asyncio.sleep(delay)
217
+ except asyncio.CancelledError:
218
+ return
219
+
220
+ async def _consume(
221
+ self,
222
+ resp: Any,
223
+ on_event: EventCallback,
224
+ opts: SubscribeOptions,
225
+ sub: Subscription,
226
+ ) -> None:
227
+ # SSE frame parser mirroring consumeStream/dispatchEvent EXACTLY. httpx aiter_lines() yields
228
+ # each line (newline stripped) and an empty string '' for a blank-line frame separator.
229
+ current_event = ""
230
+ current_data = ""
231
+ current_id = ""
232
+ async for line in resp.aiter_lines():
233
+ if sub._closed:
234
+ return
235
+ if line == "":
236
+ if current_data:
237
+ if current_id:
238
+ sub._last_event_id = current_id
239
+ self._dispatch(current_event, current_data, current_id, on_event, opts)
240
+ current_event = ""
241
+ current_data = ""
242
+ current_id = ""
243
+ continue
244
+ if line.startswith(":"): # comment / keep-alive
245
+ continue
246
+ colon = line.find(":")
247
+ if colon == -1:
248
+ field_name, value = line, ""
249
+ else:
250
+ field_name = line[:colon]
251
+ value = line[colon + 1 :]
252
+ if value.startswith(" "): # strip exactly one leading space
253
+ value = value[1:]
254
+ if field_name == "event":
255
+ current_event = value
256
+ elif field_name == "data":
257
+ current_data = f"{current_data}\n{value}" if current_data else value
258
+ elif field_name == "id":
259
+ current_id = value
260
+
261
+ def _dispatch(
262
+ self,
263
+ event: str,
264
+ data: str,
265
+ id_: str,
266
+ on_event: EventCallback,
267
+ opts: SubscribeOptions,
268
+ ) -> None:
269
+ # 1. PARSE — a malformed SSE payload skips the frame (mirrors realtime.ts). Kept narrow so a bad
270
+ # payload can never be confused with a consumer-callback error handled separately below.
271
+ try:
272
+ if event == "ready":
273
+ evt = StreamEvent("ready", json.loads(data))
274
+ elif event == "change":
275
+ evt = StreamEvent("change", json.loads(data), id=id_ or None)
276
+ elif event == "heartbeat":
277
+ evt = StreamEvent("heartbeat", int(data))
278
+ elif event == "resync":
279
+ evt = StreamEvent("resync", json.loads(data))
280
+ else:
281
+ return
282
+ except (ValueError, TypeError):
283
+ return # malformed SSE data — skip the frame
284
+
285
+ # 2. DISPATCH — a consumer callback (on_event / on_resync) raising must NEVER propagate into _run's
286
+ # reconnect `except Exception`: otherwise a buggy callback (e.g. a KeyError on a missing field)
287
+ # turns into an unbounded ticket-mint + reconnect storm against /streams/tickets. Swallow here,
288
+ # exactly like realtime.ts's dispatchEvent catch — callback failures are isolated from transport
289
+ # failures, and the stream keeps flowing.
290
+ try:
291
+ if evt.event == "resync" and opts.on_resync:
292
+ opts.on_resync(_resync_reason(evt.data))
293
+ on_event(evt)
294
+ except Exception:
295
+ pass
memybase/_self.py ADDED
@@ -0,0 +1,137 @@
1
+ """
2
+ @fileoverview Customer self-service operations on /api/self/*.
3
+ @module memybase._self
4
+ @description Mirrors the /api/self router 1:1: profile, openapi, projects CRUD + archive/restore,
5
+ databases CRUD, schema versions/activate, api-keys,
6
+ settings, plan, usage. All methods require tenant-level auth.
7
+ @created 2026-07-04
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+ from urllib.parse import quote
13
+
14
+ # Absolute import of the shared/pure module (same pattern as _collection.py / _transport.py): unasync
15
+ # generates memybase/_sync/_self.py, whose relative `from ._types` would resolve to a nonexistent
16
+ # memybase/_sync/_types.py — the absolute path makes both the async module and its sync twin bind the
17
+ # SAME single _types module.
18
+ from memybase._types import SchemaActivationJobSummary
19
+
20
+ from ._transport import Transport
21
+
22
+ __all__ = ["SelfService"]
23
+
24
+ _e = lambda s: quote(str(s), safe="") # noqa: E731
25
+
26
+
27
+ class SelfService:
28
+ """Async handle for authenticated /api/self/* customer endpoints."""
29
+
30
+ def __init__(self, transport: Transport) -> None:
31
+ self._t = transport
32
+
33
+ # ── Profile ──────────────────────────────────────────────────────────────
34
+ async def profile(self) -> Any:
35
+ return await self._t.request("GET", "/api/self/profile")
36
+
37
+ async def openapi(self) -> Any:
38
+ return await self._t.request("GET", "/api/self/openapi.json")
39
+
40
+ # ── Projects ─────────────────────────────────────────────────────────────
41
+ async def list_projects(self) -> Any:
42
+ return await self._t.request("GET", "/api/self/projects")
43
+
44
+ async def create_project(self, body: dict[str, Any]) -> Any:
45
+ return await self._t.request("POST", "/api/self/projects", json_body=body)
46
+
47
+ async def get_project(self, ref: str) -> Any:
48
+ return await self._t.request("GET", f"/api/self/projects/{_e(ref)}")
49
+
50
+ async def update_project(self, ref: str, body: dict[str, Any]) -> Any:
51
+ return await self._t.request("PATCH", f"/api/self/projects/{_e(ref)}", json_body=body)
52
+
53
+ async def archive_project(self, ref: str) -> None:
54
+ await self._t.request("POST", f"/api/self/projects/{_e(ref)}/archive")
55
+
56
+ async def restore_project(self, ref: str) -> None:
57
+ await self._t.request("POST", f"/api/self/projects/{_e(ref)}/restore")
58
+
59
+ async def delete_project(self, ref: str) -> None:
60
+ await self._t.request("DELETE", f"/api/self/projects/{_e(ref)}")
61
+
62
+ # ── Databases ────────────────────────────────────────────────────────────
63
+ async def list_databases(self, project_ref: str) -> Any:
64
+ return await self._t.request("GET", f"/api/self/projects/{_e(project_ref)}/databases")
65
+
66
+ async def create_database(self, project_ref: str, body: dict[str, Any]) -> Any:
67
+ return await self._t.request("POST", f"/api/self/projects/{_e(project_ref)}/databases", json_body=body)
68
+
69
+ async def delete_database(self, project_ref: str, db: str) -> None:
70
+ await self._t.request("DELETE", f"/api/self/projects/{_e(project_ref)}/databases/{_e(db)}")
71
+
72
+ async def archive_database(self, project_ref: str, db: str) -> None:
73
+ await self._t.request("POST", f"/api/self/projects/{_e(project_ref)}/databases/{_e(db)}/archive")
74
+
75
+ async def restore_database(self, project_ref: str, db: str) -> None:
76
+ await self._t.request("POST", f"/api/self/projects/{_e(project_ref)}/databases/{_e(db)}/restore")
77
+
78
+ # ── Schema ───────────────────────────────────────────────────────────────
79
+ async def list_schema_versions(self, project_ref: str, db: str | None = None) -> Any:
80
+ base = f"/api/self/projects/{_e(project_ref)}"
81
+ path = f"{base}/databases/{_e(db)}/schema/versions" if db else f"{base}/schema/versions"
82
+ return await self._t.request("GET", path)
83
+
84
+ async def activate_schema(self, project_ref: str, draft: dict[str, Any], db: str | None = None) -> Any:
85
+ base = f"/api/self/projects/{_e(project_ref)}"
86
+ path = f"{base}/databases/{_e(db)}/schema/activate" if db else f"{base}/schema/activate"
87
+ return await self._t.request("POST", path, json_body=draft)
88
+
89
+ async def activate_schema_spec(self, project_ref: str, spec: dict[str, Any], db: str | None = None) -> Any:
90
+ base = f"/api/self/projects/{_e(project_ref)}"
91
+ path = f"{base}/databases/{_e(db)}/schema/design" if db else f"{base}/schema/design"
92
+ return await self._t.request("POST", path, json_body=spec)
93
+
94
+ async def get_schema_activation(
95
+ self, project_ref: str, job_id: str, db: str | None = None
96
+ ) -> SchemaActivationJobSummary:
97
+ base = f"/api/self/projects/{_e(project_ref)}"
98
+ path = f"{base}/databases/{_e(db)}/schema/activations/{_e(job_id)}" if db else f"{base}/schema/activations/{_e(job_id)}"
99
+ return await self._t.request("GET", path)
100
+
101
+ async def rollback_schema_activation(
102
+ self, project_ref: str, job_id: str, db: str | None = None
103
+ ) -> SchemaActivationJobSummary:
104
+ base = f"/api/self/projects/{_e(project_ref)}"
105
+ path = f"{base}/databases/{_e(db)}/schema/activations/{_e(job_id)}/rollback" if db else f"{base}/schema/activations/{_e(job_id)}/rollback"
106
+ return await self._t.request("POST", path)
107
+
108
+ # ── Settings ─────────────────────────────────────────────────────────────
109
+ async def get_settings(self, scope_type: str | None = None, scope_id: str | None = None) -> Any:
110
+ if scope_type and scope_id:
111
+ return await self._t.request("GET", f"/api/self/settings/{_e(scope_type)}/{_e(scope_id)}")
112
+ return await self._t.request("GET", "/api/self/settings")
113
+
114
+ async def update_settings(self, values: dict[str, Any], scope_type: str | None = None, scope_id: str | None = None) -> Any:
115
+ if scope_type and scope_id:
116
+ return await self._t.request("PUT", f"/api/self/settings/{_e(scope_type)}/{_e(scope_id)}", json_body=values)
117
+ return await self._t.request("PUT", "/api/self/settings", json_body=values)
118
+
119
+ # ── API Keys ─────────────────────────────────────────────────────────────
120
+ async def list_api_keys(self) -> Any:
121
+ return await self._t.request("GET", "/api/self/api-keys")
122
+
123
+ async def create_api_key(self, body: dict[str, Any]) -> Any:
124
+ return await self._t.request("POST", "/api/self/api-keys", json_body=body)
125
+
126
+ async def revoke_api_key(self, key_id: str) -> None:
127
+ await self._t.request("DELETE", f"/api/self/api-keys/{_e(key_id)}")
128
+
129
+ async def purge_api_key(self, key_id: str) -> None:
130
+ await self._t.request("DELETE", f"/api/self/api-keys/{_e(key_id)}/purge")
131
+
132
+ # ── Plan & Usage ─────────────────────────────────────────────────────────
133
+ async def get_plan(self) -> Any:
134
+ return await self._t.request("GET", "/api/self/plan")
135
+
136
+ async def get_usage(self) -> Any:
137
+ return await self._t.request("GET", "/api/self/usage")
@@ -0,0 +1,3 @@
1
+ # GENERATED by scripts/gen_sync.py from memybase/__init__.py — DO NOT EDIT.
2
+ # Regenerate: python sdk/py/scripts/gen_sync.py (CI gate: gen_sync.py --check).
3
+ """Generated synchronous twins of the memybase async modules — see scripts/gen_sync.py."""