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.
- zerodb_supabase/__init__.py +29 -0
- zerodb_supabase/client.py +113 -0
- zerodb_supabase/functions.py +121 -0
- zerodb_supabase/provision.py +102 -0
- zerodb_supabase/query.py +407 -0
- zerodb_supabase/storage.py +299 -0
- zerodb_supabase-0.1.0.dist-info/METADATA +244 -0
- zerodb_supabase-0.1.0.dist-info/RECORD +11 -0
- zerodb_supabase-0.1.0.dist-info/WHEEL +5 -0
- zerodb_supabase-0.1.0.dist-info/licenses/LICENSE +21 -0
- zerodb_supabase-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
zerodb_supabase/query.py
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/zerodb-supabase/)
|
|
37
|
+
[](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,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
|