zerodb-supabase 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,29 @@
1
+ """
2
+ zerodb-supabase -- Drop-in Supabase Python client replacement backed by ZeroDB.
3
+
4
+ Change one import, keep the same API:
5
+
6
+ # Before (supabase-py)
7
+ from supabase import create_client
8
+
9
+ # After (zerodb-supabase)
10
+ from zerodb_supabase import create_client
11
+
12
+ Free cloud database. No Supabase account needed.
13
+ Auto-provisions on first use.
14
+ """
15
+
16
+ from zerodb_supabase.client import Client, create_client # noqa: F401
17
+ from zerodb_supabase.query import QueryBuilder # noqa: F401
18
+ from zerodb_supabase.storage import StorageClient, StorageBucket # noqa: F401
19
+ from zerodb_supabase.functions import FunctionsClient # noqa: F401
20
+
21
+ __version__ = "0.1.0"
22
+ __all__ = [
23
+ "Client",
24
+ "create_client",
25
+ "QueryBuilder",
26
+ "StorageClient",
27
+ "StorageBucket",
28
+ "FunctionsClient",
29
+ ]
@@ -0,0 +1,113 @@
1
+ """
2
+ Client -- Supabase-compatible client backed by ZeroDB.
3
+
4
+ Drop-in replacement: same interface as supabase.Client.
5
+
6
+ from zerodb_supabase import create_client
7
+
8
+ client = create_client()
9
+ data = client.table('users').select('*').execute()
10
+ """
11
+
12
+ import requests
13
+
14
+ from zerodb_supabase.provision import resolve_credentials
15
+ from zerodb_supabase.query import QueryBuilder
16
+ from zerodb_supabase.storage import StorageClient
17
+ from zerodb_supabase.functions import FunctionsClient
18
+
19
+
20
+ def create_client(supabase_url=None, supabase_key=None, **kwargs):
21
+ """Create a Supabase-compatible client backed by ZeroDB.
22
+
23
+ Supabase-compatible signature. If no URL/key provided, auto-provisions
24
+ a free ZeroDB project.
25
+
26
+ Args:
27
+ supabase_url: Ignored (kept for Supabase API compatibility).
28
+ Use ZERODB_BASE_URL env var to override the ZeroDB endpoint.
29
+ supabase_key: Used as ZeroDB API key if provided.
30
+ **kwargs: Extra options (api_key, project_id, base_url).
31
+
32
+ Returns:
33
+ Client instance.
34
+ """
35
+ api_key = kwargs.get("api_key") or supabase_key
36
+ project_id = kwargs.get("project_id")
37
+ base_url = kwargs.get("base_url")
38
+ return Client(api_key=api_key, project_id=project_id, base_url=base_url)
39
+
40
+
41
+ class Client:
42
+ """Supabase-compatible client backed by ZeroDB.
43
+
44
+ Provides:
45
+ - table(name) -> QueryBuilder for CRUD operations
46
+ - storage -> StorageClient for file operations
47
+ - functions -> FunctionsClient for serverless functions
48
+ """
49
+
50
+ def __init__(self, api_key=None, project_id=None, base_url=None):
51
+ self._api_key, self._project_id, self._base_url = resolve_credentials(
52
+ api_key=api_key,
53
+ project_id=project_id,
54
+ )
55
+ if base_url:
56
+ self._base_url = base_url
57
+
58
+ self._session = requests.Session()
59
+ self._session.headers.update({
60
+ "Content-Type": "application/json",
61
+ "Authorization": f"Bearer {self._api_key}",
62
+ "X-Project-ID": self._project_id,
63
+ })
64
+
65
+ self._tables_url = f"{self._base_url}/api/v1/public/tables"
66
+ self._files_url = f"{self._base_url}/api/v1/public/files"
67
+ self._hooks_url = f"{self._base_url}/api/v1/public/hooks"
68
+
69
+ self._storage = None
70
+ self._functions = None
71
+
72
+ @property
73
+ def storage(self):
74
+ """Access storage operations (S3-compatible via ZeroDB files API)."""
75
+ if self._storage is None:
76
+ self._storage = StorageClient(self._session, self._files_url)
77
+ return self._storage
78
+
79
+ @property
80
+ def functions(self):
81
+ """Access serverless functions (ZeroDB hooks API)."""
82
+ if self._functions is None:
83
+ self._functions = FunctionsClient(self._session, self._hooks_url)
84
+ return self._functions
85
+
86
+ def table(self, table_name):
87
+ """Start a query on a table. Returns a QueryBuilder.
88
+
89
+ Args:
90
+ table_name: Name of the table to query.
91
+
92
+ Returns:
93
+ QueryBuilder instance for chaining operations.
94
+ """
95
+ return QueryBuilder(self._session, self._tables_url, table_name)
96
+
97
+ def from_(self, table_name):
98
+ """Alias for table(). Matches supabase-py's from_() method."""
99
+ return self.table(table_name)
100
+
101
+ def rpc(self, function_name, params=None):
102
+ """Call a stored procedure / RPC function.
103
+
104
+ Maps to ZeroDB hooks API.
105
+
106
+ Args:
107
+ function_name: Name of the function.
108
+ params: Dict of parameters to pass.
109
+
110
+ Returns:
111
+ APIResponse with the result.
112
+ """
113
+ return self.functions.invoke(function_name, params or {})
@@ -0,0 +1,121 @@
1
+ """
2
+ FunctionsClient -- Supabase Edge Functions-compatible interface backed by ZeroDB hooks API.
3
+
4
+ result = client.functions.invoke('process-upload', {'file_id': '123'})
5
+ """
6
+
7
+
8
+ class FunctionResponse:
9
+ """Response wrapper for function invocations."""
10
+
11
+ def __init__(self, data=None, status_code=200):
12
+ self.data = data
13
+ self.status_code = status_code
14
+
15
+ def __repr__(self):
16
+ return f"FunctionResponse(data={self.data!r})"
17
+
18
+
19
+ class FunctionsClient:
20
+ """Supabase FunctionsClient-compatible interface backed by ZeroDB hooks API.
21
+
22
+ Maps Supabase Edge Functions to ZeroDB's hooks/functions system.
23
+ """
24
+
25
+ def __init__(self, session, hooks_url):
26
+ self._session = session
27
+ self._hooks_url = hooks_url
28
+
29
+ def invoke(self, function_name, invoke_options=None):
30
+ """Invoke a serverless function.
31
+
32
+ Args:
33
+ function_name: Name of the function to invoke.
34
+ invoke_options: Dict with body, headers, method, etc.
35
+ Compatible with Supabase invoke options.
36
+
37
+ Returns:
38
+ FunctionResponse with the result.
39
+ """
40
+ options = invoke_options or {}
41
+
42
+ # Supabase supports body as dict or the options dict itself
43
+ body = options.get("body", options) if isinstance(options, dict) else options
44
+
45
+ # Build request
46
+ headers = {}
47
+ if isinstance(options, dict) and "headers" in options:
48
+ headers = options["headers"]
49
+
50
+ method = "POST"
51
+ if isinstance(options, dict) and "method" in options:
52
+ method = options["method"].upper()
53
+
54
+ resp = self._session.request(
55
+ method,
56
+ f"{self._hooks_url}/invoke/{function_name}",
57
+ json=body if method in ("POST", "PUT", "PATCH") else None,
58
+ params=body if method == "GET" else None,
59
+ headers=headers,
60
+ )
61
+ resp.raise_for_status()
62
+
63
+ try:
64
+ data = resp.json()
65
+ except ValueError:
66
+ data = resp.text
67
+
68
+ return FunctionResponse(data=data, status_code=resp.status_code)
69
+
70
+ def list(self):
71
+ """List all available functions.
72
+
73
+ Returns:
74
+ list of function info dicts.
75
+ """
76
+ resp = self._session.get(f"{self._hooks_url}/list")
77
+ resp.raise_for_status()
78
+ return resp.json()
79
+
80
+ def get(self, function_name):
81
+ """Get info about a specific function.
82
+
83
+ Args:
84
+ function_name: Function name.
85
+
86
+ Returns:
87
+ dict with function info.
88
+ """
89
+ resp = self._session.get(f"{self._hooks_url}/{function_name}")
90
+ resp.raise_for_status()
91
+ return resp.json()
92
+
93
+ def create(self, function_name, body=None):
94
+ """Create/deploy a new function.
95
+
96
+ Args:
97
+ function_name: Function name.
98
+ body: Dict with function code/config.
99
+
100
+ Returns:
101
+ dict with created function info.
102
+ """
103
+ payload = {"name": function_name}
104
+ if body:
105
+ payload.update(body)
106
+ resp = self._session.post(f"{self._hooks_url}/create", json=payload)
107
+ resp.raise_for_status()
108
+ return resp.json()
109
+
110
+ def delete(self, function_name):
111
+ """Delete a function.
112
+
113
+ Args:
114
+ function_name: Function name.
115
+
116
+ Returns:
117
+ dict with result.
118
+ """
119
+ resp = self._session.delete(f"{self._hooks_url}/{function_name}")
120
+ resp.raise_for_status()
121
+ return resp.json()
@@ -0,0 +1,102 @@
1
+ """
2
+ Auto-provisioning for ZeroDB.
3
+
4
+ Resolves credentials in order:
5
+ 1. Explicit constructor args
6
+ 2. Environment variables (ZERODB_API_KEY, ZERODB_PROJECT_ID)
7
+ 3. Config file (~/.zerodb/config.json)
8
+ 4. Auto-provision via Instant DB endpoint (free, no signup)
9
+ """
10
+
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+
15
+ import requests
16
+
17
+ ZERODB_API_BASE = "https://api.ainative.studio"
18
+ INSTANT_DB_ENDPOINT = f"{ZERODB_API_BASE}/api/v1/public/instant-db"
19
+ CONFIG_DIR = Path.home() / ".zerodb"
20
+ CONFIG_FILE = CONFIG_DIR / "config.json"
21
+
22
+
23
+ def _load_config_file():
24
+ """Load credentials from ~/.zerodb/config.json if it exists."""
25
+ if CONFIG_FILE.exists():
26
+ try:
27
+ data = json.loads(CONFIG_FILE.read_text())
28
+ api_key = data.get("api_key")
29
+ project_id = data.get("project_id")
30
+ if api_key and project_id:
31
+ return api_key, project_id
32
+ except (json.JSONDecodeError, OSError):
33
+ pass
34
+ return None, None
35
+
36
+
37
+ def _save_config_file(api_key, project_id, claim_url=None):
38
+ """Save credentials to ~/.zerodb/config.json."""
39
+ try:
40
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
41
+ data = {"api_key": api_key, "project_id": project_id}
42
+ if claim_url:
43
+ data["claim_url"] = claim_url
44
+ CONFIG_FILE.write_text(json.dumps(data, indent=2))
45
+ except OSError:
46
+ pass # Best-effort -- don't crash if home dir is read-only
47
+
48
+
49
+ def _auto_provision():
50
+ """Create an instant ZeroDB project (free, no signup required)."""
51
+ resp = requests.post(
52
+ INSTANT_DB_ENDPOINT,
53
+ json={"source": "zerodb-supabase"},
54
+ headers={"Content-Type": "application/json"},
55
+ timeout=30,
56
+ )
57
+ resp.raise_for_status()
58
+ data = resp.json()
59
+
60
+ api_key = data["api_key"]
61
+ project_id = data["project_id"]
62
+ claim_url = data.get("claim_url")
63
+
64
+ # Persist for future runs
65
+ _save_config_file(api_key, project_id, claim_url)
66
+
67
+ if claim_url:
68
+ print(
69
+ f"\n zerodb-supabase: Auto-provisioned free project."
70
+ f"\n Claim it at: {claim_url}"
71
+ f"\n Project: {project_id}"
72
+ f"\n This message only appears once.\n"
73
+ )
74
+
75
+ return api_key, project_id
76
+
77
+
78
+ def resolve_credentials(api_key=None, project_id=None):
79
+ """Resolve ZeroDB credentials from args, env, config, or auto-provision.
80
+
81
+ Returns (api_key, project_id, base_url).
82
+ """
83
+ base_url = os.environ.get("ZERODB_BASE_URL", ZERODB_API_BASE)
84
+
85
+ # 1. Explicit args
86
+ if api_key and project_id:
87
+ return api_key, project_id, base_url
88
+
89
+ # 2. Environment variables
90
+ env_key = os.environ.get("ZERODB_API_KEY")
91
+ env_project = os.environ.get("ZERODB_PROJECT_ID")
92
+ if env_key and env_project:
93
+ return env_key, env_project, base_url
94
+
95
+ # 3. Config file
96
+ file_key, file_project = _load_config_file()
97
+ if file_key and file_project:
98
+ return file_key, file_project, base_url
99
+
100
+ # 4. Auto-provision
101
+ auto_key, auto_project = _auto_provision()
102
+ return auto_key, auto_project, base_url
@@ -0,0 +1,407 @@
1
+ """
2
+ QueryBuilder -- Supabase-compatible query builder for ZeroDB tables.
3
+
4
+ Supports the Supabase chaining pattern:
5
+
6
+ client.table('users').select('*').eq('active', True).limit(10).execute()
7
+ """
8
+
9
+
10
+ class APIResponse:
11
+ """Response wrapper matching Supabase APIResponse."""
12
+
13
+ def __init__(self, data, count=None, status_code=200):
14
+ self.data = data
15
+ self.count = count
16
+ self.status_code = status_code
17
+
18
+ def __repr__(self):
19
+ return f"APIResponse(data={self.data!r}, count={self.count})"
20
+
21
+
22
+ class QueryBuilder:
23
+ """Chainable query builder that maps Supabase query API to ZeroDB.
24
+
25
+ Usage:
26
+ qb = QueryBuilder(session, base_url, 'users')
27
+ result = qb.select('*').eq('active', True).execute()
28
+ """
29
+
30
+ def __init__(self, session, tables_url, table_name):
31
+ self._session = session
32
+ self._tables_url = tables_url
33
+ self._table = table_name
34
+ self._operation = None # 'select', 'insert', 'update', 'upsert', 'delete'
35
+ self._columns = "*"
36
+ self._filters = []
37
+ self._order_col = None
38
+ self._order_asc = True
39
+ self._limit_val = None
40
+ self._offset_val = None
41
+ self._data = None
42
+ self._count_mode = None
43
+ self._single = False
44
+
45
+ # ------------------------------------------------------------------
46
+ # Operations
47
+ # ------------------------------------------------------------------
48
+
49
+ def select(self, columns="*", count=None):
50
+ """Select columns from the table.
51
+
52
+ Args:
53
+ columns: Column names (comma-separated string or '*').
54
+ count: Count mode ('exact', 'planned', 'estimated') or None.
55
+
56
+ Returns:
57
+ self for chaining.
58
+ """
59
+ self._operation = "select"
60
+ self._columns = columns
61
+ self._count_mode = count
62
+ return self
63
+
64
+ def insert(self, data, count=None):
65
+ """Insert row(s) into the table.
66
+
67
+ Args:
68
+ data: Dict (single row) or list of dicts (multiple rows).
69
+ count: Count mode or None.
70
+
71
+ Returns:
72
+ self for chaining.
73
+ """
74
+ self._operation = "insert"
75
+ self._data = data if isinstance(data, list) else [data]
76
+ self._count_mode = count
77
+ return self
78
+
79
+ def update(self, data, count=None):
80
+ """Update rows matching filters.
81
+
82
+ Args:
83
+ data: Dict of columns to update.
84
+ count: Count mode or None.
85
+
86
+ Returns:
87
+ self for chaining.
88
+ """
89
+ self._operation = "update"
90
+ self._data = data
91
+ self._count_mode = count
92
+ return self
93
+
94
+ def upsert(self, data, count=None):
95
+ """Insert or update row(s).
96
+
97
+ Args:
98
+ data: Dict or list of dicts.
99
+ count: Count mode or None.
100
+
101
+ Returns:
102
+ self for chaining.
103
+ """
104
+ self._operation = "upsert"
105
+ self._data = data if isinstance(data, list) else [data]
106
+ self._count_mode = count
107
+ return self
108
+
109
+ def delete(self, count=None):
110
+ """Delete rows matching filters.
111
+
112
+ Args:
113
+ count: Count mode or None.
114
+
115
+ Returns:
116
+ self for chaining.
117
+ """
118
+ self._operation = "delete"
119
+ self._count_mode = count
120
+ return self
121
+
122
+ # ------------------------------------------------------------------
123
+ # Filters (PostgREST-compatible)
124
+ # ------------------------------------------------------------------
125
+
126
+ def eq(self, column, value):
127
+ """Equal to."""
128
+ self._filters.append({"column": column, "op": "eq", "value": value})
129
+ return self
130
+
131
+ def neq(self, column, value):
132
+ """Not equal to."""
133
+ self._filters.append({"column": column, "op": "neq", "value": value})
134
+ return self
135
+
136
+ def gt(self, column, value):
137
+ """Greater than."""
138
+ self._filters.append({"column": column, "op": "gt", "value": value})
139
+ return self
140
+
141
+ def gte(self, column, value):
142
+ """Greater than or equal to."""
143
+ self._filters.append({"column": column, "op": "gte", "value": value})
144
+ return self
145
+
146
+ def lt(self, column, value):
147
+ """Less than."""
148
+ self._filters.append({"column": column, "op": "lt", "value": value})
149
+ return self
150
+
151
+ def lte(self, column, value):
152
+ """Less than or equal to."""
153
+ self._filters.append({"column": column, "op": "lte", "value": value})
154
+ return self
155
+
156
+ def like(self, column, pattern):
157
+ """LIKE pattern match."""
158
+ self._filters.append({"column": column, "op": "like", "value": pattern})
159
+ return self
160
+
161
+ def ilike(self, column, pattern):
162
+ """Case-insensitive LIKE pattern match."""
163
+ self._filters.append({"column": column, "op": "ilike", "value": pattern})
164
+ return self
165
+
166
+ def is_(self, column, value):
167
+ """IS check (for null/true/false)."""
168
+ self._filters.append({"column": column, "op": "is", "value": value})
169
+ return self
170
+
171
+ def in_(self, column, values):
172
+ """IN check (value in list)."""
173
+ self._filters.append({"column": column, "op": "in", "value": values})
174
+ return self
175
+
176
+ def contains(self, column, value):
177
+ """Contains (for arrays/JSON)."""
178
+ self._filters.append({"column": column, "op": "cs", "value": value})
179
+ return self
180
+
181
+ def contained_by(self, column, value):
182
+ """Contained by (for arrays/JSON)."""
183
+ self._filters.append({"column": column, "op": "cd", "value": value})
184
+ return self
185
+
186
+ def not_(self, column, op, value):
187
+ """Negate a filter."""
188
+ self._filters.append({"column": column, "op": f"not.{op}", "value": value})
189
+ return self
190
+
191
+ def or_(self, *filters):
192
+ """OR filter (comma-separated PostgREST conditions)."""
193
+ self._filters.append({"op": "or", "value": filters})
194
+ return self
195
+
196
+ def filter(self, column, op, value):
197
+ """Generic filter."""
198
+ self._filters.append({"column": column, "op": op, "value": value})
199
+ return self
200
+
201
+ # ------------------------------------------------------------------
202
+ # Modifiers
203
+ # ------------------------------------------------------------------
204
+
205
+ def order(self, column, desc=False):
206
+ """Order results by column.
207
+
208
+ Args:
209
+ column: Column name.
210
+ desc: If True, order descending.
211
+
212
+ Returns:
213
+ self for chaining.
214
+ """
215
+ self._order_col = column
216
+ self._order_asc = not desc
217
+ return self
218
+
219
+ def limit(self, count):
220
+ """Limit number of results.
221
+
222
+ Args:
223
+ count: Maximum number of rows.
224
+
225
+ Returns:
226
+ self for chaining.
227
+ """
228
+ self._limit_val = count
229
+ return self
230
+
231
+ def offset(self, count):
232
+ """Skip first N results (for pagination).
233
+
234
+ Args:
235
+ count: Number of rows to skip.
236
+
237
+ Returns:
238
+ self for chaining.
239
+ """
240
+ self._offset_val = count
241
+ return self
242
+
243
+ def range(self, start, end):
244
+ """Limit to a range of rows.
245
+
246
+ Args:
247
+ start: Start index.
248
+ end: End index (inclusive).
249
+
250
+ Returns:
251
+ self for chaining.
252
+ """
253
+ self._offset_val = start
254
+ self._limit_val = end - start + 1
255
+ return self
256
+
257
+ def single(self):
258
+ """Return a single row instead of a list.
259
+
260
+ Returns:
261
+ self for chaining.
262
+ """
263
+ self._single = True
264
+ self._limit_val = 1
265
+ return self
266
+
267
+ def maybe_single(self):
268
+ """Return a single row or None.
269
+
270
+ Returns:
271
+ self for chaining.
272
+ """
273
+ self._single = True
274
+ self._limit_val = 1
275
+ return self
276
+
277
+ # ------------------------------------------------------------------
278
+ # Execution
279
+ # ------------------------------------------------------------------
280
+
281
+ def _build_query_body(self):
282
+ """Build the request body for ZeroDB tables API."""
283
+ body = {"table": self._table}
284
+
285
+ if self._filters:
286
+ body["filters"] = self._filters
287
+
288
+ if self._columns != "*":
289
+ body["columns"] = [c.strip() for c in self._columns.split(",")]
290
+
291
+ if self._order_col:
292
+ body["order_by"] = {
293
+ "column": self._order_col,
294
+ "ascending": self._order_asc,
295
+ }
296
+
297
+ if self._limit_val is not None:
298
+ body["limit"] = self._limit_val
299
+
300
+ if self._offset_val is not None:
301
+ body["offset"] = self._offset_val
302
+
303
+ return body
304
+
305
+ def execute(self):
306
+ """Execute the query and return an APIResponse.
307
+
308
+ Returns:
309
+ APIResponse with data and optional count.
310
+ """
311
+ if self._operation == "select":
312
+ return self._execute_select()
313
+ elif self._operation == "insert":
314
+ return self._execute_insert()
315
+ elif self._operation == "update":
316
+ return self._execute_update()
317
+ elif self._operation == "upsert":
318
+ return self._execute_upsert()
319
+ elif self._operation == "delete":
320
+ return self._execute_delete()
321
+ else:
322
+ raise ValueError(
323
+ "No operation specified. Call select(), insert(), update(), "
324
+ "upsert(), or delete() before execute()."
325
+ )
326
+
327
+ def _execute_select(self):
328
+ """Execute a SELECT query via ZeroDB tables API."""
329
+ body = self._build_query_body()
330
+ body["operation"] = "query"
331
+
332
+ resp = self._session.post(f"{self._tables_url}/query", json=body)
333
+ resp.raise_for_status()
334
+ result = resp.json()
335
+
336
+ rows = result.get("rows", result.get("data", result))
337
+ if isinstance(rows, dict):
338
+ rows = rows.get("rows", [rows])
339
+
340
+ count = result.get("count") if self._count_mode else None
341
+
342
+ if self._single:
343
+ rows = rows[0] if rows else None
344
+
345
+ return APIResponse(data=rows, count=count, status_code=resp.status_code)
346
+
347
+ def _execute_insert(self):
348
+ """Execute an INSERT via ZeroDB tables API."""
349
+ body = {
350
+ "table": self._table,
351
+ "operation": "insert",
352
+ "rows": self._data,
353
+ }
354
+
355
+ resp = self._session.post(f"{self._tables_url}/insert", json=body)
356
+ resp.raise_for_status()
357
+ result = resp.json()
358
+
359
+ inserted = result.get("rows", result.get("data", self._data))
360
+ return APIResponse(data=inserted, status_code=resp.status_code)
361
+
362
+ def _execute_update(self):
363
+ """Execute an UPDATE via ZeroDB tables API."""
364
+ body = {
365
+ "table": self._table,
366
+ "operation": "update",
367
+ "data": self._data,
368
+ "filters": self._filters,
369
+ }
370
+
371
+ resp = self._session.post(f"{self._tables_url}/update", json=body)
372
+ resp.raise_for_status()
373
+ result = resp.json()
374
+
375
+ updated = result.get("rows", result.get("data", []))
376
+ return APIResponse(data=updated, status_code=resp.status_code)
377
+
378
+ def _execute_upsert(self):
379
+ """Execute an UPSERT via ZeroDB tables API."""
380
+ body = {
381
+ "table": self._table,
382
+ "operation": "upsert",
383
+ "rows": self._data,
384
+ }
385
+
386
+ resp = self._session.post(f"{self._tables_url}/upsert", json=body)
387
+ resp.raise_for_status()
388
+ result = resp.json()
389
+
390
+ upserted = result.get("rows", result.get("data", self._data))
391
+ return APIResponse(data=upserted, status_code=resp.status_code)
392
+
393
+ def _execute_delete(self):
394
+ """Execute a DELETE via ZeroDB tables API."""
395
+ body = {
396
+ "table": self._table,
397
+ "operation": "delete",
398
+ "filters": self._filters,
399
+ }
400
+
401
+ resp = self._session.post(f"{self._tables_url}/delete", json=body)
402
+ resp.raise_for_status()
403
+ result = resp.json()
404
+
405
+ deleted = result.get("rows", result.get("data", []))
406
+ count = result.get("count")
407
+ return APIResponse(data=deleted, count=count, status_code=resp.status_code)
@@ -0,0 +1,299 @@
1
+ """
2
+ StorageClient -- Supabase Storage-compatible interface backed by ZeroDB files API.
3
+
4
+ client.storage.from_('avatars').upload('avatar.png', file_data)
5
+ url = client.storage.from_('avatars').get_public_url('avatar.png')
6
+ client.storage.from_('avatars').download('avatar.png')
7
+ """
8
+
9
+
10
+ class StorageFileResponse:
11
+ """Response wrapper for storage operations."""
12
+
13
+ def __init__(self, data=None, path=None, status_code=200):
14
+ self.data = data
15
+ self.path = path
16
+ self.status_code = status_code
17
+
18
+ def __repr__(self):
19
+ return f"StorageFileResponse(path={self.path!r})"
20
+
21
+
22
+ class StorageBucket:
23
+ """Supabase StorageBucket-compatible interface for a single bucket.
24
+
25
+ Maps to ZeroDB files API with bucket prefix as directory.
26
+ """
27
+
28
+ def __init__(self, session, files_url, bucket_name):
29
+ self._session = session
30
+ self._files_url = files_url
31
+ self._bucket = bucket_name
32
+
33
+ def _file_path(self, path):
34
+ """Build full file path with bucket prefix."""
35
+ return f"{self._bucket}/{path}"
36
+
37
+ def upload(self, path, file_data, file_options=None):
38
+ """Upload a file to the bucket.
39
+
40
+ Args:
41
+ path: File path within the bucket.
42
+ file_data: File content (bytes or file-like object).
43
+ file_options: Optional dict with content_type, cache_control, etc.
44
+
45
+ Returns:
46
+ StorageFileResponse with upload result.
47
+ """
48
+ full_path = self._file_path(path)
49
+ content_type = "application/octet-stream"
50
+ if file_options and "content_type" in file_options:
51
+ content_type = file_options["content_type"]
52
+ elif file_options and "contentType" in file_options:
53
+ content_type = file_options["contentType"]
54
+
55
+ # Use multipart upload for ZeroDB files API
56
+ files = {"file": (path, file_data, content_type)}
57
+ data = {"path": full_path}
58
+
59
+ # Temporarily remove JSON content-type for multipart
60
+ headers = dict(self._session.headers)
61
+ headers.pop("Content-Type", None)
62
+
63
+ resp = self._session.post(
64
+ f"{self._files_url}/upload",
65
+ files=files,
66
+ data=data,
67
+ headers=headers,
68
+ )
69
+ resp.raise_for_status()
70
+ result = resp.json()
71
+
72
+ return StorageFileResponse(
73
+ data=result,
74
+ path=full_path,
75
+ status_code=resp.status_code,
76
+ )
77
+
78
+ def download(self, path):
79
+ """Download a file from the bucket.
80
+
81
+ Args:
82
+ path: File path within the bucket.
83
+
84
+ Returns:
85
+ bytes -- file content.
86
+ """
87
+ full_path = self._file_path(path)
88
+ resp = self._session.get(
89
+ f"{self._files_url}/download",
90
+ params={"path": full_path},
91
+ )
92
+ resp.raise_for_status()
93
+ return resp.content
94
+
95
+ def get_public_url(self, path):
96
+ """Get a public URL for a file.
97
+
98
+ Args:
99
+ path: File path within the bucket.
100
+
101
+ Returns:
102
+ str -- public URL.
103
+ """
104
+ full_path = self._file_path(path)
105
+ resp = self._session.post(
106
+ f"{self._files_url}/url",
107
+ json={"path": full_path},
108
+ )
109
+ resp.raise_for_status()
110
+ result = resp.json()
111
+ return result.get("url", result.get("public_url", ""))
112
+
113
+ def create_signed_url(self, path, expires_in=3600):
114
+ """Create a signed URL for temporary access.
115
+
116
+ Args:
117
+ path: File path within the bucket.
118
+ expires_in: Seconds until expiration (default 1 hour).
119
+
120
+ Returns:
121
+ dict with signedURL.
122
+ """
123
+ full_path = self._file_path(path)
124
+ resp = self._session.post(
125
+ f"{self._files_url}/url",
126
+ json={"path": full_path, "expires_in": expires_in},
127
+ )
128
+ resp.raise_for_status()
129
+ result = resp.json()
130
+ return {"signedURL": result.get("url", result.get("signed_url", ""))}
131
+
132
+ def remove(self, paths):
133
+ """Remove file(s) from the bucket.
134
+
135
+ Args:
136
+ paths: List of file paths to remove.
137
+
138
+ Returns:
139
+ list of removed file info.
140
+ """
141
+ full_paths = [self._file_path(p) for p in paths]
142
+ resp = self._session.post(
143
+ f"{self._files_url}/delete",
144
+ json={"paths": full_paths},
145
+ )
146
+ resp.raise_for_status()
147
+ return resp.json()
148
+
149
+ def list(self, path="", limit=100, offset=0, sort_by=None):
150
+ """List files in the bucket.
151
+
152
+ Args:
153
+ path: Directory path prefix.
154
+ limit: Max results.
155
+ offset: Pagination offset.
156
+ sort_by: Dict with column and order.
157
+
158
+ Returns:
159
+ list of file metadata dicts.
160
+ """
161
+ prefix = self._file_path(path) if path else self._bucket
162
+ resp = self._session.get(
163
+ f"{self._files_url}/list",
164
+ params={"prefix": prefix, "limit": limit, "offset": offset},
165
+ )
166
+ resp.raise_for_status()
167
+ result = resp.json()
168
+ return result.get("files", result if isinstance(result, list) else [])
169
+
170
+ def move(self, from_path, to_path):
171
+ """Move/rename a file within the bucket.
172
+
173
+ Args:
174
+ from_path: Source file path.
175
+ to_path: Destination file path.
176
+
177
+ Returns:
178
+ dict with result.
179
+ """
180
+ resp = self._session.post(
181
+ f"{self._files_url}/move",
182
+ json={
183
+ "from": self._file_path(from_path),
184
+ "to": self._file_path(to_path),
185
+ },
186
+ )
187
+ resp.raise_for_status()
188
+ return resp.json()
189
+
190
+ def copy(self, from_path, to_path):
191
+ """Copy a file within the bucket.
192
+
193
+ Args:
194
+ from_path: Source file path.
195
+ to_path: Destination file path.
196
+
197
+ Returns:
198
+ dict with result.
199
+ """
200
+ resp = self._session.post(
201
+ f"{self._files_url}/copy",
202
+ json={
203
+ "from": self._file_path(from_path),
204
+ "to": self._file_path(to_path),
205
+ },
206
+ )
207
+ resp.raise_for_status()
208
+ return resp.json()
209
+
210
+
211
+ class StorageClient:
212
+ """Supabase StorageClient-compatible interface.
213
+
214
+ Usage:
215
+ client.storage.from_('bucket-name').upload(...)
216
+ """
217
+
218
+ def __init__(self, session, files_url):
219
+ self._session = session
220
+ self._files_url = files_url
221
+
222
+ def from_(self, bucket_name):
223
+ """Get a StorageBucket instance for the named bucket.
224
+
225
+ Args:
226
+ bucket_name: Name of the storage bucket.
227
+
228
+ Returns:
229
+ StorageBucket instance.
230
+ """
231
+ return StorageBucket(self._session, self._files_url, bucket_name)
232
+
233
+ def list_buckets(self):
234
+ """List all buckets.
235
+
236
+ Returns:
237
+ list of bucket info dicts.
238
+ """
239
+ resp = self._session.get(f"{self._files_url}/buckets")
240
+ resp.raise_for_status()
241
+ return resp.json()
242
+
243
+ def get_bucket(self, bucket_id):
244
+ """Get bucket info by ID.
245
+
246
+ Args:
247
+ bucket_id: Bucket name/ID.
248
+
249
+ Returns:
250
+ dict with bucket info.
251
+ """
252
+ resp = self._session.get(f"{self._files_url}/buckets/{bucket_id}")
253
+ resp.raise_for_status()
254
+ return resp.json()
255
+
256
+ def create_bucket(self, bucket_id, options=None):
257
+ """Create a new bucket.
258
+
259
+ Args:
260
+ bucket_id: Bucket name.
261
+ options: Optional dict with public, file_size_limit, etc.
262
+
263
+ Returns:
264
+ dict with created bucket info.
265
+ """
266
+ body = {"name": bucket_id}
267
+ if options:
268
+ body.update(options)
269
+ resp = self._session.post(f"{self._files_url}/buckets", json=body)
270
+ resp.raise_for_status()
271
+ return resp.json()
272
+
273
+ def delete_bucket(self, bucket_id):
274
+ """Delete a bucket.
275
+
276
+ Args:
277
+ bucket_id: Bucket name/ID.
278
+
279
+ Returns:
280
+ dict with result.
281
+ """
282
+ resp = self._session.delete(f"{self._files_url}/buckets/{bucket_id}")
283
+ resp.raise_for_status()
284
+ return resp.json()
285
+
286
+ def empty_bucket(self, bucket_id):
287
+ """Empty all files in a bucket.
288
+
289
+ Args:
290
+ bucket_id: Bucket name/ID.
291
+
292
+ Returns:
293
+ dict with result.
294
+ """
295
+ resp = self._session.post(
296
+ f"{self._files_url}/buckets/{bucket_id}/empty"
297
+ )
298
+ resp.raise_for_status()
299
+ return resp.json()
@@ -0,0 +1,244 @@
1
+ Metadata-Version: 2.4
2
+ Name: zerodb-supabase
3
+ Version: 0.1.0
4
+ Summary: Drop-in Supabase Python client replacement backed by ZeroDB. Same API, free cloud database.
5
+ Author-email: AINative Studio <dev@ainative.studio>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/AINative-Studio/zerodb-supabase
8
+ Project-URL: Documentation, https://docs.ainative.studio
9
+ Project-URL: Repository, https://github.com/AINative-Studio/zerodb-supabase
10
+ Project-URL: Issues, https://github.com/AINative-Studio/zerodb-supabase/issues
11
+ Keywords: supabase,supabase-alternative,supabase-py,supabase-python,supabase-client,database,postgres,rest-api,zerodb,ainative,baas,backend-as-a-service,storage,file-storage,s3-compatible,edge-functions,serverless-functions,realtime,vector-database,embeddings,ai-database,free-database,firebase-alternative,neon-alternative,planetscale-alternative,mcp,claude,cursor
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Database
22
+ Classifier: Topic :: Internet :: WWW/HTTP
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Requires-Python: >=3.8
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: requests>=2.28
28
+ Dynamic: license-file
29
+
30
+ # zerodb-supabase
31
+
32
+ **Drop-in replacement for [supabase-py](https://pypi.org/project/supabase/) backed by ZeroDB.**
33
+
34
+ Same API. Free cloud database. No Supabase account needed.
35
+
36
+ [![PyPI](https://img.shields.io/pypi/v/zerodb-supabase)](https://pypi.org/project/zerodb-supabase/)
37
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
38
+
39
+ ## Why switch?
40
+
41
+ | | supabase-py | zerodb-supabase |
42
+ |---|---|---|
43
+ | **Database** | Supabase Cloud (paid after free tier) | ZeroDB (free tier) |
44
+ | **Storage** | Supabase Storage (limited) | ZeroDB S3-compatible (generous) |
45
+ | **Functions** | Edge Functions (Deno only) | ZeroDB Functions (Python, JS, TS) |
46
+ | **Provisioning** | Manual dashboard setup | Auto-provision on first use |
47
+ | **Vectors** | pgvector extension | Built-in vector search |
48
+ | **AI Memory** | Not available | ZeroMemory cognitive memory |
49
+ | **License** | Apache-2.0 | MIT |
50
+
51
+ ## Installation
52
+
53
+ ```bash
54
+ pip install zerodb-supabase
55
+ ```
56
+
57
+ ## Migration (30 seconds)
58
+
59
+ Change one import:
60
+
61
+ ```python
62
+ # Before
63
+ from supabase import create_client
64
+
65
+ # After
66
+ from zerodb_supabase import create_client
67
+ ```
68
+
69
+ That's it. Every method works the same.
70
+
71
+ ## Quick Start
72
+
73
+ ```python
74
+ from zerodb_supabase import create_client
75
+
76
+ # Auto-provisions a free ZeroDB project on first use
77
+ client = create_client()
78
+
79
+ # Query data
80
+ data = client.table('users').select('*').eq('active', True).execute()
81
+ print(data.data) # [{'id': 1, 'name': 'Alice', 'active': True}, ...]
82
+
83
+ # Insert data
84
+ client.table('users').insert({
85
+ 'name': 'Alice',
86
+ 'email': 'alice@example.com'
87
+ }).execute()
88
+
89
+ # Update data
90
+ client.table('users').update({
91
+ 'name': 'Alice Updated'
92
+ }).eq('id', 1).execute()
93
+
94
+ # Delete data
95
+ client.table('users').delete().eq('id', 1).execute()
96
+
97
+ # Storage (S3-compatible)
98
+ client.storage.from_('avatars').upload('avatar.png', open('avatar.png', 'rb'))
99
+ url = client.storage.from_('avatars').get_public_url('avatar.png')
100
+
101
+ # Functions (ZeroDB Functions)
102
+ result = client.functions.invoke('process-upload', {'file_id': '123'})
103
+ print(result.data)
104
+ ```
105
+
106
+ ## API Reference
107
+
108
+ ### `create_client(supabase_url=None, supabase_key=None, **kwargs)`
109
+
110
+ Create a client. Credentials resolved in order:
111
+ 1. Constructor arguments (`api_key`, `project_id`)
112
+ 2. Supabase-compatible args (`supabase_key` used as API key)
113
+ 3. Environment variables (`ZERODB_API_KEY`, `ZERODB_PROJECT_ID`)
114
+ 4. Config file (`~/.zerodb/config.json`)
115
+ 5. Auto-provision (free, no signup)
116
+
117
+ ### Query Builder
118
+
119
+ | Method | Description |
120
+ |--------|-------------|
121
+ | `table(name).select('*')` | Select rows |
122
+ | `table(name).insert(data)` | Insert row(s) |
123
+ | `table(name).update(data)` | Update rows |
124
+ | `table(name).upsert(data)` | Insert or update |
125
+ | `table(name).delete()` | Delete rows |
126
+
127
+ ### Filters (Chainable)
128
+
129
+ | Method | SQL Equivalent |
130
+ |--------|---------------|
131
+ | `.eq(col, val)` | `WHERE col = val` |
132
+ | `.neq(col, val)` | `WHERE col != val` |
133
+ | `.gt(col, val)` | `WHERE col > val` |
134
+ | `.gte(col, val)` | `WHERE col >= val` |
135
+ | `.lt(col, val)` | `WHERE col < val` |
136
+ | `.lte(col, val)` | `WHERE col <= val` |
137
+ | `.like(col, pattern)` | `WHERE col LIKE pattern` |
138
+ | `.ilike(col, pattern)` | `WHERE col ILIKE pattern` |
139
+ | `.is_(col, val)` | `WHERE col IS val` |
140
+ | `.in_(col, list)` | `WHERE col IN (list)` |
141
+ | `.contains(col, val)` | `WHERE col @> val` |
142
+ | `.not_(col, op, val)` | Negate filter |
143
+
144
+ ### Modifiers
145
+
146
+ | Method | Description |
147
+ |--------|-------------|
148
+ | `.order(col, desc=False)` | Order results |
149
+ | `.limit(n)` | Limit results |
150
+ | `.offset(n)` | Skip rows |
151
+ | `.range(start, end)` | Row range |
152
+ | `.single()` | Return one row |
153
+ | `.maybe_single()` | Return one or None |
154
+
155
+ ### Storage
156
+
157
+ ```python
158
+ bucket = client.storage.from_('bucket-name')
159
+ bucket.upload('path/file.png', file_data)
160
+ bucket.download('path/file.png')
161
+ bucket.get_public_url('path/file.png')
162
+ bucket.create_signed_url('path/file.png', expires_in=3600)
163
+ bucket.remove(['file1.png', 'file2.png'])
164
+ bucket.list()
165
+ bucket.move('old.png', 'new.png')
166
+ bucket.copy('src.png', 'dst.png')
167
+ ```
168
+
169
+ ### Functions
170
+
171
+ ```python
172
+ # Invoke a function
173
+ result = client.functions.invoke('function-name', {'key': 'value'})
174
+
175
+ # List functions
176
+ functions = client.functions.list()
177
+
178
+ # RPC shorthand
179
+ result = client.rpc('function-name', {'param': 'value'})
180
+ ```
181
+
182
+ ## Configuration
183
+
184
+ ### Environment Variables
185
+
186
+ ```bash
187
+ export ZERODB_API_KEY="your-api-key"
188
+ export ZERODB_PROJECT_ID="your-project-id"
189
+ # Optional: custom endpoint
190
+ export ZERODB_BASE_URL="https://api.ainative.studio"
191
+ ```
192
+
193
+ ### Config File
194
+
195
+ ```json
196
+ // ~/.zerodb/config.json
197
+ {
198
+ "api_key": "your-api-key",
199
+ "project_id": "your-project-id"
200
+ }
201
+ ```
202
+
203
+ ### Auto-Provisioning
204
+
205
+ If no credentials are found, `zerodb-supabase` automatically creates a free ZeroDB project. Credentials are saved to `~/.zerodb/config.json` for future use.
206
+
207
+ ## Supabase Migration Guide
208
+
209
+ ### 1. Install
210
+
211
+ ```bash
212
+ pip uninstall supabase
213
+ pip install zerodb-supabase
214
+ ```
215
+
216
+ ### 2. Update imports
217
+
218
+ ```python
219
+ # Before
220
+ from supabase import create_client, Client
221
+
222
+ # After
223
+ from zerodb_supabase import create_client, Client
224
+ ```
225
+
226
+ ### 3. Remove Supabase config
227
+
228
+ No more `SUPABASE_URL` or `SUPABASE_KEY` environment variables needed. ZeroDB auto-provisions.
229
+
230
+ ### 4. That's it
231
+
232
+ All your queries, storage calls, and function invocations work unchanged.
233
+
234
+ ---
235
+
236
+ **Built by [AINative Studio](https://ainative.studio)**
237
+
238
+ Free database for AI agents. Auto-provisions in 200ms.
239
+
240
+ [Get started](https://ainative.studio) | [Documentation](https://docs.ainative.studio) | [GitHub](https://github.com/AINative-Studio/zerodb-supabase)
241
+
242
+ ## License
243
+
244
+ MIT
@@ -0,0 +1,11 @@
1
+ zerodb_supabase/__init__.py,sha256=T_PgFXlmZt_EsTLKJVClq63mtdTOwxQrndjXfgFA7Sw,788
2
+ zerodb_supabase/client.py,sha256=Sc0Qu0ssUlzouDQuROCZ2yzWjBxJqmVXtaDrymPL9Zk,3645
3
+ zerodb_supabase/functions.py,sha256=_mB-K_HqZ534GuNT9y_Aaf2wxJR-q3_inRfGXJNgDDI,3493
4
+ zerodb_supabase/provision.py,sha256=oKSDHdM06DkGOCC7zTFEGUQ1c84j5Uf3h59cNfuEyMU,3053
5
+ zerodb_supabase/query.py,sha256=8sqkBZt9p3w2LZYllZRZUfPJfV2pFei9WWKv0In8q1Y,12077
6
+ zerodb_supabase/storage.py,sha256=FV2uyNbkClpmM4eypWyxtkxgbpuq9aliZVtI1NLvRxk,8511
7
+ zerodb_supabase-0.1.0.dist-info/licenses/LICENSE,sha256=-3M2h1U80S6mPyiuvRG25A0l1xZGpa7eO43lysBIzBY,1072
8
+ zerodb_supabase-0.1.0.dist-info/METADATA,sha256=LajhM0QJZu8yclixS0DYe49ArU50HwwwXf2sttihJtI,7162
9
+ zerodb_supabase-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ zerodb_supabase-0.1.0.dist-info/top_level.txt,sha256=1hoz3TjOnTShLqfWGbYtXrrkXWv1pb7FU2orKc-2ZSg,16
11
+ zerodb_supabase-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AINative Studio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ zerodb_supabase