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,219 @@
1
+ # GENERATED by scripts/gen_sync.py from memybase/_client.py — DO NOT EDIT.
2
+ # Regenerate: python sdk/py/scripts/gen_sync.py (CI gate: gen_sync.py --check).
3
+ """
4
+ @fileoverview Top-level MemyBase async client.
5
+ @module memybase._client
6
+ @description Entry point: MemyBase(base_url, api_key=...) → .collection("slug", project=...) →
7
+ CRUD. Exposes customer management via .self, and GraphQL via .graphql().
8
+ Includes key-gated discovery helpers for descriptors and OpenAPI.
9
+ @created 2026-07-04
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Callable, Optional
14
+ from urllib.parse import quote
15
+
16
+ from ._collection import Collection
17
+ from ._graphql import GraphQLClient
18
+ from ._self import SelfService
19
+ from ._transport import Transport, RetryConfig
20
+
21
+ __all__ = ["MemyBase"]
22
+
23
+ SDK_VERSION = "0.1.0"
24
+
25
+
26
+ def _idempotency_headers(idempotency_key: Optional[str] = None) -> Optional[dict[str, str]]:
27
+ return {"Idempotency-Key": idempotency_key} if idempotency_key is not None else None
28
+
29
+
30
+ class MemyBase:
31
+ """Async MemyBase client.
32
+
33
+ Usage::
34
+
35
+ async with MemyBase("https://memybase.example.com", api_key="pbk_...") as mb:
36
+ customers = mb.collection("customers", project="crm", database="analytics")
37
+ page = await customers.list(page_size=10)
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ base_url: str,
43
+ *,
44
+ api_key: Optional[str] = None,
45
+ token: Optional[str] = None,
46
+ user_token: Optional[str] = None,
47
+ cookie: Optional[str] = None,
48
+ headers: Optional[dict[str, str]] = None,
49
+ timeout: float = 30.0,
50
+ max_retries: Optional[int] = None,
51
+ retry: Optional[RetryConfig] = None,
52
+ transport: Optional[Any] = None,
53
+ ) -> None:
54
+ self._transport = Transport(
55
+ base_url,
56
+ api_key=api_key,
57
+ token=token,
58
+ user_token=user_token,
59
+ cookie=cookie,
60
+ headers=headers,
61
+ timeout=timeout,
62
+ max_retries=max_retries,
63
+ retry=retry,
64
+ transport=transport,
65
+ )
66
+ self._self = SelfService(self._transport)
67
+
68
+ @staticmethod
69
+ def _data_prefix(project: str, database: Optional[str]) -> str:
70
+ """The data-plane path prefix for a project/database — multi-db (/p/{p}/d/{db}) or legacy
71
+ (/projects/{p}). The `_batch`, `admin/schema`, and `openapi.json` routes hang off it."""
72
+ if database:
73
+ return f"/api/v1/p/{quote(project, safe='')}/d/{quote(database, safe='')}"
74
+ return f"/api/v1/projects/{quote(project, safe='')}"
75
+
76
+ def collection(
77
+ self,
78
+ slug: str,
79
+ *,
80
+ project: str,
81
+ database: Optional[str] = None,
82
+ ) -> Collection:
83
+ """Get a handle for a data-plane entity collection."""
84
+ return Collection(
85
+ self._transport,
86
+ slug,
87
+ project=project,
88
+ database=database,
89
+ )
90
+
91
+ def graphql(
92
+ self,
93
+ *,
94
+ project: str,
95
+ database: Optional[str] = None,
96
+ ) -> GraphQLClient:
97
+ """Get a GraphQL client for a specific project."""
98
+ return GraphQLClient(
99
+ self._transport,
100
+ project=project,
101
+ database=database,
102
+ )
103
+
104
+ def batch(
105
+ self,
106
+ project: str,
107
+ database: Optional[str],
108
+ operations: list[dict[str, Any]],
109
+ *,
110
+ idempotency_key: Optional[str] = None,
111
+ ) -> dict[str, Any]:
112
+ """Cross-entity atomic batch write → POST {prefix}/_batch.
113
+
114
+ ``operations`` is an ordered list of ``{op, entity, id?, data?, reason?}`` (each op names its own
115
+ entity), applied all-or-nothing in ONE placement transaction; the first failing op aborts the
116
+ batch (its error naming the op index). Use ``collection.bulk`` for the single-entity variant.
117
+ """
118
+ path = f"{self._data_prefix(project, database)}/_batch"
119
+ return self._transport.request(
120
+ "POST", path, json_body={"operations": operations}, headers=_idempotency_headers(idempotency_key)
121
+ )
122
+
123
+ def prepare_ai_proposal(
124
+ self,
125
+ project: str,
126
+ database: Optional[str],
127
+ input: dict[str, Any],
128
+ ) -> dict[str, Any]:
129
+ """Prepare a project/database-scoped AI mutation for approval.
130
+
131
+ Non-AI credentials return ``{"status": "not_applicable"}``; AI credentials return the durable
132
+ approval-required proposal envelope and never execute the mutation here.
133
+ """
134
+ return self._transport.request(
135
+ "POST", f"{self._data_prefix(project, database)}/ai-proposals/prepare", json_body=input
136
+ )
137
+
138
+ def get_ai_proposal(self, project: str, database: Optional[str], proposal_id: str) -> dict[str, Any]:
139
+ """Read one proposal scoped by the addressed project and database."""
140
+ path = f"{self._data_prefix(project, database)}/ai-proposals/{quote(proposal_id, safe='')}"
141
+ return self._transport.request("GET", path)
142
+
143
+ def approve_ai_proposal(
144
+ self,
145
+ project: str,
146
+ database: Optional[str],
147
+ proposal_id: str,
148
+ *,
149
+ reason: Optional[str] = None,
150
+ policy_decision_reason: Optional[str] = None,
151
+ ) -> dict[str, Any]:
152
+ """Approve and execute once; the server enforces non-AI approval policy."""
153
+ body = {
154
+ key: value
155
+ for key, value in {
156
+ "reason": reason,
157
+ "policyDecisionReason": policy_decision_reason,
158
+ }.items()
159
+ if value is not None
160
+ }
161
+ path = f"{self._data_prefix(project, database)}/ai-proposals/{quote(proposal_id, safe='')}/approve"
162
+ return self._transport.request("POST", path, json_body=body)
163
+
164
+ def reject_ai_proposal(
165
+ self,
166
+ project: str,
167
+ database: Optional[str],
168
+ proposal_id: str,
169
+ *,
170
+ reason: Optional[str] = None,
171
+ policy_decision_reason: Optional[str] = None,
172
+ ) -> dict[str, Any]:
173
+ """Reject a pending proposal; the server enforces non-AI approval policy."""
174
+ body = {
175
+ key: value
176
+ for key, value in {
177
+ "reason": reason,
178
+ "policyDecisionReason": policy_decision_reason,
179
+ }.items()
180
+ if value is not None
181
+ }
182
+ path = f"{self._data_prefix(project, database)}/ai-proposals/{quote(proposal_id, safe='')}/reject"
183
+ return self._transport.request("POST", path, json_body=body)
184
+
185
+ def describe(self, project: str, database: Optional[str] = None) -> dict[str, Any]:
186
+ """GET {prefix}/admin/schema — the key-gated data-plane app descriptor (entities/fields metadata,
187
+ structure only). Lets a key-only consumer introspect the database it holds a key for."""
188
+ return self._transport.request("GET", f"{self._data_prefix(project, database)}/admin/schema")
189
+
190
+ def describe_entity(self, project: str, database: Optional[str], slug: str) -> dict[str, Any]:
191
+ """GET {prefix}/admin/entities/{slug} — the key-gated descriptor for one entity."""
192
+ path = f"{self._data_prefix(project, database)}/admin/entities/{quote(slug, safe='')}"
193
+ return self._transport.request("GET", path)
194
+
195
+ def openapi(self, project: str, database: Optional[str] = None) -> dict[str, Any]:
196
+ """GET {prefix}/openapi.json — the key-gated data-plane OpenAPI 3.1 contract generated from
197
+ the active compiled schema."""
198
+ return self._transport.request("GET", f"{self._data_prefix(project, database)}/openapi.json")
199
+
200
+ def health(self, timeout: Optional[float] = None) -> bool:
201
+ """Unauthenticated liveness probe → GET /api/health (process-up; no Mongo, schema, or key required).
202
+ True on a 2xx, False on any non-OK / network error / timeout — a cheap, schema-independent
203
+ reachability signal for readiness gates, safe to call BEFORE a schema is provisioned."""
204
+ return self._transport.health(timeout)
205
+
206
+ @property
207
+ def self_(self) -> SelfService:
208
+ """Authenticated customer management (/api/self/*)."""
209
+ return self._self
210
+
211
+
212
+ def close(self) -> None:
213
+ self._transport.close()
214
+
215
+ def __enter__(self) -> "MemyBase":
216
+ return self
217
+
218
+ def __exit__(self, *exc: Any) -> None:
219
+ self.close()
@@ -0,0 +1,383 @@
1
+ # GENERATED by scripts/gen_sync.py from memybase/_collection.py — DO NOT EDIT.
2
+ # Regenerate: python sdk/py/scripts/gen_sync.py (CI gate: gen_sync.py --check).
3
+ """
4
+ @fileoverview Async Collection handle — selector→handle pattern for data-plane operations.
5
+ @module memybase._collection
6
+ @description Implements the data-plane operations (list, create, get, update, softDelete, restore,
7
+ hardDelete, versions, audit, bulk) plus listAll auto-pagination and the full read surface
8
+ (fields projection, expand populate, search FTS, after cursor, count opt-out, includeDeleted).
9
+ Supports both multi-db (/p/{project}/d/{database}/e/{slug}) and legacy
10
+ (/projects/{project}/entities/{slug}).
11
+ @created 2026-07-04
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from typing import Any, Iterator, Callable, Optional
17
+ from urllib.parse import quote
18
+
19
+ # Shared/pure modules use ABSOLUTE imports so the generated memybase/_sync/_collection.py twin resolves
20
+ # the single parent module (there is no _sync/_errors etc.); sibling modules with a twin (_transport)
21
+ # stay RELATIVE so they follow into _sync/. See scripts/gen_sync.py.
22
+ from memybase._errors import ReservedFieldError
23
+ from memybase._filter import FilterBuilder, serialize_filter
24
+ from memybase._mapping import guard_reserved_fields
25
+ from memybase._mapping import omit_none as _omit_none
26
+ from ._transport import Transport
27
+
28
+ __all__ = ["Collection"]
29
+
30
+
31
+ def _mutation_headers(
32
+ expected_version: Optional[int] = None,
33
+ idempotency_key: Optional[str] = None,
34
+ ) -> Optional[dict[str, str]]:
35
+ headers: dict[str, str] = {}
36
+ if expected_version is not None:
37
+ headers["If-Match"] = str(expected_version)
38
+ if idempotency_key is not None:
39
+ headers["Idempotency-Key"] = idempotency_key
40
+ return headers or None
41
+
42
+
43
+ class Collection:
44
+ """Async handle for one entity collection on a specific project/database."""
45
+
46
+ def __init__(
47
+ self,
48
+ transport: Transport,
49
+ slug: str,
50
+ *,
51
+ project: str,
52
+ database: Optional[str] = None,
53
+ ) -> None:
54
+ self._transport = transport
55
+ self._slug = slug
56
+ self._project = project
57
+ self._database = database
58
+
59
+ if database:
60
+ self._base = f"/api/v1/p/{quote(project, safe='')}/d/{quote(database, safe='')}/e/{quote(slug, safe='')}"
61
+ else:
62
+ self._base = f"/api/v1/projects/{quote(project, safe='')}/entities/{quote(slug, safe='')}"
63
+
64
+ def _list_params(
65
+ self,
66
+ *,
67
+ page: Optional[int],
68
+ page_size: Optional[int],
69
+ sort: Optional[str],
70
+ filter: Optional[list[dict[str, Any]] | FilterBuilder],
71
+ include_deleted: bool,
72
+ fields: Optional[list[str]],
73
+ expand: Optional[list[str]],
74
+ search: Optional[str],
75
+ after: Optional[str],
76
+ count: Optional[bool],
77
+ ) -> dict[str, str]:
78
+ """Serialize list/read query params exactly as the server's parseListQuery expects (rest/router.ts).
79
+
80
+ page/pageSize are emitted ONLY when supplied so a no-arg list() lets the server apply its DEFAULT
81
+ (25) — matching JS. Advanced options (fields/expand/search/after cursor/count) round-trip the full
82
+ read surface: projection, relation populate, full-text search, keyset pagination, count opt-out.
83
+ """
84
+ params: dict[str, str] = {}
85
+ if page is not None:
86
+ params["page"] = str(page)
87
+ if page_size is not None:
88
+ params["pageSize"] = str(page_size)
89
+ if sort:
90
+ params["sort"] = sort
91
+ if filter is not None:
92
+ conditions = filter.build() if isinstance(filter, FilterBuilder) else filter
93
+ if conditions:
94
+ params["filter"] = serialize_filter(conditions)
95
+ if include_deleted:
96
+ params["includeDeleted"] = "true"
97
+ if fields:
98
+ params["fields"] = ",".join(fields)
99
+ if expand:
100
+ params["expand"] = ",".join(expand)
101
+ if search:
102
+ params["search"] = search
103
+ if after:
104
+ params["after"] = after
105
+ if count is not None:
106
+ params["count"] = "true" if count else "false"
107
+ return params
108
+
109
+ def list(
110
+ self,
111
+ *,
112
+ page: Optional[int] = None,
113
+ page_size: Optional[int] = None,
114
+ sort: Optional[str] = None,
115
+ filter: Optional[list[dict[str, Any]] | FilterBuilder] = None,
116
+ include_deleted: bool = False,
117
+ fields: Optional[list[str]] = None,
118
+ expand: Optional[list[str]] = None,
119
+ search: Optional[str] = None,
120
+ after: Optional[str] = None,
121
+ count: Optional[bool] = None,
122
+ ) -> dict[str, Any]:
123
+ """Fetch a page → {items, page, pageSize, total?, hasMore, nextCursor?}."""
124
+ params = self._list_params(
125
+ page=page, page_size=page_size, sort=sort, filter=filter,
126
+ include_deleted=include_deleted, fields=fields, expand=expand,
127
+ search=search, after=after, count=count,
128
+ )
129
+ return self._transport.request("GET", self._base, params=params)
130
+
131
+ def query(
132
+ self,
133
+ *,
134
+ page: Optional[int] = None,
135
+ page_size: Optional[int] = None,
136
+ sort: Optional[str] = None,
137
+ filter: Optional[list[dict[str, Any]] | FilterBuilder] = None,
138
+ include_deleted: bool = False,
139
+ fields: Optional[list[str]] = None,
140
+ expand: Optional[list[str]] = None,
141
+ search: Optional[str] = None,
142
+ after: Optional[str] = None,
143
+ count: Optional[bool] = None,
144
+ ) -> dict[str, Any]:
145
+ """RFC 10008 QUERY list request with a JSON body.
146
+
147
+ It is safe and idempotent like ``list()``, but keeps rich query content out of the URI.
148
+ The server applies the identical scope, field-ABAC, query-safety, and pagination contract.
149
+ """
150
+ body: dict[str, Any] = {}
151
+ if page is not None:
152
+ body["page"] = page
153
+ if page_size is not None:
154
+ body["pageSize"] = page_size
155
+ if sort:
156
+ body["sort"] = sort
157
+ if filter is not None:
158
+ conditions = filter.build() if isinstance(filter, FilterBuilder) else filter
159
+ if conditions:
160
+ body["filter"] = conditions
161
+ if include_deleted:
162
+ body["includeDeleted"] = True
163
+ if fields:
164
+ body["fields"] = fields
165
+ if expand:
166
+ body["expand"] = expand
167
+ if search:
168
+ body["search"] = search
169
+ if after:
170
+ body["after"] = after
171
+ if count is not None:
172
+ body["count"] = count
173
+ return self._transport.request("QUERY", self._base, json_body=body)
174
+
175
+ def list_all(
176
+ self,
177
+ *,
178
+ page_size: int = 100,
179
+ sort: Optional[str] = None,
180
+ filter: Optional[list[dict[str, Any]] | FilterBuilder] = None,
181
+ include_deleted: bool = False,
182
+ fields: Optional[list[str]] = None,
183
+ expand: Optional[list[str]] = None,
184
+ search: Optional[str] = None,
185
+ max_pages: int = 1000,
186
+ ) -> Iterator[dict[str, Any]]:
187
+ """Auto-paginate through all records, yielding each item.
188
+
189
+ ``max_pages`` bounds the walk; if it is exhausted while the server still reports ``hasMore=True``
190
+ a RuntimeError is raised rather than SILENTLY truncating (an ETL/export footgun). Raise the bound
191
+ (or set it very high) to walk larger collections.
192
+ """
193
+ page = 1
194
+ while True:
195
+ result = self.list(
196
+ page=page, page_size=page_size, sort=sort, filter=filter,
197
+ include_deleted=include_deleted, fields=fields, expand=expand, search=search,
198
+ )
199
+ for item in result.get("items", []):
200
+ yield item
201
+ if not result.get("hasMore", False):
202
+ return
203
+ if page >= max_pages:
204
+ raise RuntimeError(
205
+ f"list_all exhausted max_pages={max_pages} while the server still reports "
206
+ f"hasMore=True — increase max_pages to page deeper (truncation is never silent)."
207
+ )
208
+ page += 1
209
+
210
+ def get(
211
+ self,
212
+ record_id: str,
213
+ *,
214
+ fields: Optional[list[str]] = None,
215
+ expand: Optional[list[str]] = None,
216
+ ) -> dict[str, Any]:
217
+ """GET one record by id. Optional `fields` (projection) + `expand` (relation populate) mirror the
218
+ server getById query params (rest/router.ts)."""
219
+ path = f"{self._base}/{quote(record_id, safe='')}"
220
+ params: dict[str, str] = {}
221
+ if fields:
222
+ params["fields"] = ",".join(fields)
223
+ if expand:
224
+ params["expand"] = ",".join(expand)
225
+ return self._transport.request("GET", path, params=params or None)
226
+
227
+ def create(
228
+ self,
229
+ body: dict[str, Any],
230
+ *,
231
+ omit_none: bool = True,
232
+ idempotency_key: Optional[str] = None,
233
+ ) -> dict[str, Any]:
234
+ """POST a new record (201).
235
+
236
+ By default None-valued keys are stripped (``omit_none=True``): the server injects defaults, so a
237
+ null on create is never a meaningful clear. Pass ``omit_none=False`` to send the body verbatim —
238
+ for a consumer that stores explicit nulls on create (e.g. a fixed-shape record whose optional
239
+ fields must round-trip as null rather than be absent).
240
+ """
241
+ # Guard the ORIGINAL body BEFORE stripping None so {"version": None} raises ReservedFieldError
242
+ # instead of being silently omitted to a no-op (rank 24).
243
+ guard_reserved_fields(body)
244
+ payload = _omit_none(body) if omit_none else dict(body)
245
+ return self._transport.request(
246
+ "POST", self._base, json_body=payload, headers=_mutation_headers(idempotency_key=idempotency_key)
247
+ )
248
+
249
+ def update(
250
+ self,
251
+ record_id: str,
252
+ body: dict[str, Any],
253
+ *,
254
+ expected_version: Optional[int] = None,
255
+ idempotency_key: Optional[str] = None,
256
+ ) -> dict[str, Any]:
257
+ """PATCH a record.
258
+
259
+ Sends the body AS-IS (no None-stripping): the server treats a PATCH ``null`` as a first-class
260
+ sentinel — a non-nullable optional field null → ``$unset`` (clear), a nullable field null →
261
+ ``$set: null``. Stripping None (as create() does) would make field-clear unreachable and resolve
262
+ 200 while the field silently stays set. ``expected_version`` sends If-Match and stale writes fail
263
+ with 409. ``idempotency_key`` enables durable safe retry/replay. Reserved keys are still rejected.
264
+ """
265
+ guard_reserved_fields(body)
266
+ path = f"{self._base}/{quote(record_id, safe='')}"
267
+ return self._transport.request(
268
+ "PATCH", path, json_body=body, headers=_mutation_headers(expected_version, idempotency_key)
269
+ )
270
+
271
+ def bulk(self, operations: list[dict[str, Any]], *, idempotency_key: Optional[str] = None) -> dict[str, Any]:
272
+ """Atomic per-entity bulk write → POST {base}/_bulk.
273
+
274
+ ``operations`` is an ordered list of ``{op, id?, data?, reason?}`` (op ∈ create|update|delete),
275
+ all targeting THIS collection's entity, applied all-or-nothing in one placement transaction (the
276
+ first failing op aborts the batch, its error naming the op index). See MemyBase.batch for the
277
+ cross-entity variant.
278
+ """
279
+ return self._transport.request(
280
+ "POST", f"{self._base}/_bulk", json_body={"operations": operations},
281
+ headers=_mutation_headers(idempotency_key=idempotency_key),
282
+ )
283
+
284
+ def soft_delete(
285
+ self,
286
+ record_id: str,
287
+ *,
288
+ reason: Optional[str] = None,
289
+ expected_version: Optional[int] = None,
290
+ idempotency_key: Optional[str] = None,
291
+ ) -> None:
292
+ """DELETE (soft, 204)."""
293
+ path = f"{self._base}/{quote(record_id, safe='')}"
294
+ params: Optional[dict[str, str]] = None
295
+ if reason:
296
+ params = {"reason": reason}
297
+ self._transport.request(
298
+ "DELETE", path, params=params, headers=_mutation_headers(expected_version, idempotency_key)
299
+ )
300
+
301
+ def restore(
302
+ self,
303
+ record_id: str,
304
+ *,
305
+ expected_version: Optional[int] = None,
306
+ idempotency_key: Optional[str] = None,
307
+ ) -> dict[str, Any]:
308
+ """POST /{id}/restore — un-delete a soft-deleted record."""
309
+ path = f"{self._base}/{quote(record_id, safe='')}/restore"
310
+ return self._transport.request(
311
+ "POST", path, headers=_mutation_headers(expected_version, idempotency_key)
312
+ )
313
+
314
+ def hard_delete(
315
+ self,
316
+ record_id: str,
317
+ *,
318
+ expected_version: Optional[int] = None,
319
+ idempotency_key: Optional[str] = None,
320
+ ) -> None:
321
+ """DELETE /{id}/hard — permanent removal (requires elevated key)."""
322
+ path = f"{self._base}/{quote(record_id, safe='')}/hard"
323
+ self._transport.request(
324
+ "DELETE", path, headers=_mutation_headers(expected_version, idempotency_key)
325
+ )
326
+
327
+ def versions(
328
+ self,
329
+ record_id: str,
330
+ *,
331
+ page: Optional[int] = None,
332
+ page_size: Optional[int] = None,
333
+ ) -> dict[str, Any]:
334
+ """GET /{id}/versions — immutable version snapshots. page/pageSize sent only when supplied
335
+ (the server applies its own default cap), mirroring JS."""
336
+ path = f"{self._base}/{quote(record_id, safe='')}/versions"
337
+ params: dict[str, str] = {}
338
+ if page is not None:
339
+ params["page"] = str(page)
340
+ if page_size is not None:
341
+ params["pageSize"] = str(page_size)
342
+ return self._transport.request("GET", path, params=params)
343
+
344
+ def version(self, record_id: str, version: int) -> dict[str, Any]:
345
+ """GET /{id}/versions/{version} — one immutable version snapshot."""
346
+ path = f"{self._base}/{quote(record_id, safe='')}/versions/{version}"
347
+ return self._transport.request("GET", path)
348
+
349
+ def preview_version_restore(self, record_id: str, version: int) -> dict[str, Any]:
350
+ """GET /{id}/versions/{version}/restore-preview — dry-run a historical-content restore."""
351
+ path = f"{self._base}/{quote(record_id, safe='')}/versions/{version}/restore-preview"
352
+ return self._transport.request("GET", path)
353
+
354
+ def restore_version(
355
+ self,
356
+ record_id: str,
357
+ version: int,
358
+ *,
359
+ expected_version: Optional[int] = None,
360
+ idempotency_key: Optional[str] = None,
361
+ ) -> dict[str, Any]:
362
+ """POST /{id}/versions/{version}/restore — restore historical content as a new version."""
363
+ path = f"{self._base}/{quote(record_id, safe='')}/versions/{version}/restore"
364
+ return self._transport.request(
365
+ "POST", path, headers=_mutation_headers(expected_version, idempotency_key)
366
+ )
367
+
368
+ def audit(
369
+ self,
370
+ record_id: str,
371
+ *,
372
+ page: Optional[int] = None,
373
+ page_size: Optional[int] = None,
374
+ ) -> dict[str, Any]:
375
+ """GET /{id}/audit — before/after/diff audit trail. page/pageSize sent only when supplied."""
376
+ path = f"{self._base}/{quote(record_id, safe='')}/audit"
377
+ params: dict[str, str] = {}
378
+ if page is not None:
379
+ params["page"] = str(page)
380
+ if page_size is not None:
381
+ params["pageSize"] = str(page_size)
382
+ return self._transport.request("GET", path, params=params)
383
+
@@ -0,0 +1,51 @@
1
+ # GENERATED by scripts/gen_sync.py from memybase/_graphql.py — DO NOT EDIT.
2
+ # Regenerate: python sdk/py/scripts/gen_sync.py (CI gate: gen_sync.py --check).
3
+ """
4
+ @fileoverview GraphQL read-only client for per-project generated schemas.
5
+ @module memybase._graphql
6
+ @description Sends GraphQL queries against the per-project /api/v1 graphql endpoint and reads the
7
+ generated schema.sdl artifact as text. Read-only (no mutations/subscriptions). API keys
8
+ work. Mirrors the JS SDK's GraphQLClient.
9
+ @created 2026-07-04
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Optional
14
+ from urllib.parse import quote
15
+
16
+ from ._transport import Transport
17
+
18
+ __all__ = ["GraphQLClient"]
19
+
20
+
21
+ class GraphQLClient:
22
+ """Async GraphQL client for a specific project (optional database)."""
23
+
24
+ def __init__(
25
+ self,
26
+ transport: Transport,
27
+ *,
28
+ project: str,
29
+ database: Optional[str] = None,
30
+ ) -> None:
31
+ self._transport = transport
32
+ if database:
33
+ self._path = f"/api/v1/p/{quote(project, safe='')}/d/{quote(database, safe='')}/graphql"
34
+ else:
35
+ self._path = f"/api/v1/projects/{quote(project, safe='')}/graphql"
36
+
37
+ def query(self, document: str, variables: Optional[dict[str, Any]] = None) -> Any:
38
+ """Execute a GraphQL query and return the response body ({data, errors?})."""
39
+ body: dict[str, Any] = {"query": document}
40
+ if variables:
41
+ body["variables"] = variables
42
+ return self._transport.request("POST", self._path, json_body=body)
43
+
44
+ def schema_sdl(self) -> str:
45
+ """GET {graphql_path}/schema.sdl as application/graphql text."""
46
+ return self._transport.request(
47
+ "GET",
48
+ f"{self._path}/schema.sdl",
49
+ headers={"Accept": "application/graphql"},
50
+ text=True,
51
+ )
@@ -0,0 +1,37 @@
1
+ # GENERATED by scripts/gen_sync.py from memybase/_helpers.py — DO NOT EDIT.
2
+ # Regenerate: python sdk/py/scripts/gen_sync.py (CI gate: gen_sync.py --check).
3
+ """
4
+ @fileoverview Conflict retry helper for expected-version race conditions.
5
+ @module memybase._helpers
6
+ @description MemyBase writes accept expected_version / If-Match preconditions; this helper retries on
7
+ ConflictError with linear backoff, mirroring the JS SDK's withConflictRetry().
8
+ @created 2026-07-04
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from typing import Any, Awaitable, Callable, TypeVar
14
+
15
+ from memybase._errors import ConflictError # absolute: shared module, no _sync twin (see gen_sync.py)
16
+
17
+ __all__ = ["with_conflict_retry"]
18
+
19
+ T = TypeVar("T")
20
+
21
+
22
+ def with_conflict_retry(
23
+ fn: Callable[[], T],
24
+ *,
25
+ retries: int = 3,
26
+ delay: float = 0.2,
27
+ ) -> T:
28
+ """Retry fn() on ConflictError up to `retries` times with linear backoff."""
29
+ last_error: ConflictError | None = None
30
+ for attempt in range(retries + 1):
31
+ try:
32
+ return fn()
33
+ except ConflictError as e:
34
+ last_error = e
35
+ if attempt < retries:
36
+ time.sleep(delay * (attempt + 1))
37
+ raise last_error # type: ignore[misc]