postbase 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.
- postbase/__init__.py +89 -0
- postbase/_internal.py +158 -0
- postbase/_query_state.py +100 -0
- postbase/aio/__init__.py +25 -0
- postbase/aio/auth.py +584 -0
- postbase/aio/client.py +164 -0
- postbase/aio/email.py +54 -0
- postbase/aio/query.py +269 -0
- postbase/aio/storage.py +293 -0
- postbase/auth.py +592 -0
- postbase/client.py +171 -0
- postbase/email.py +54 -0
- postbase/py.typed +0 -0
- postbase/query.py +268 -0
- postbase/storage.py +291 -0
- postbase/types.py +233 -0
- postbase-0.1.0.dist-info/METADATA +755 -0
- postbase-0.1.0.dist-info/RECORD +19 -0
- postbase-0.1.0.dist-info/WHEEL +4 -0
postbase/__init__.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""postbase — the official Python client for Postbase.
|
|
2
|
+
|
|
3
|
+
Postbase is a self-hosted, open-source backend-as-a-service built on
|
|
4
|
+
PostgreSQL: a REST query API, authentication, file storage, and row-level
|
|
5
|
+
security.
|
|
6
|
+
|
|
7
|
+
Quick start (sync)::
|
|
8
|
+
|
|
9
|
+
from postbase import create_client
|
|
10
|
+
|
|
11
|
+
postbase = create_client(
|
|
12
|
+
"https://your-postbase-instance.com",
|
|
13
|
+
"pb_anon_your_api_key",
|
|
14
|
+
project_id="your-project-id",
|
|
15
|
+
)
|
|
16
|
+
result = postbase.from_("posts").select().eq("status", "published").execute()
|
|
17
|
+
|
|
18
|
+
Quick start (async)::
|
|
19
|
+
|
|
20
|
+
from postbase.aio import create_async_client
|
|
21
|
+
|
|
22
|
+
postbase = create_async_client(
|
|
23
|
+
"https://your-postbase-instance.com",
|
|
24
|
+
"pb_anon_your_api_key",
|
|
25
|
+
project_id="your-project-id",
|
|
26
|
+
)
|
|
27
|
+
result = await postbase.from_("posts").select().eq("status", "published").execute()
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from .auth import AuthAdminClient, AuthClient
|
|
31
|
+
from .client import Client, create_client
|
|
32
|
+
from .email import EmailClient
|
|
33
|
+
from .query import DeleteBuilder, InsertBuilder, QueryBuilder, UpdateBuilder
|
|
34
|
+
from .storage import StorageBucketClient, StorageClient
|
|
35
|
+
from .types import (
|
|
36
|
+
AuthChangeEvent,
|
|
37
|
+
AuthResponse,
|
|
38
|
+
AuthUser,
|
|
39
|
+
Bucket,
|
|
40
|
+
Cookie,
|
|
41
|
+
CookieAdapter,
|
|
42
|
+
CookieToSet,
|
|
43
|
+
Filter,
|
|
44
|
+
FilterOperator,
|
|
45
|
+
JoinClause,
|
|
46
|
+
JoinType,
|
|
47
|
+
NativeOAuthProvider,
|
|
48
|
+
QueryResult,
|
|
49
|
+
RealtimeEvent,
|
|
50
|
+
RealtimePayload,
|
|
51
|
+
Session,
|
|
52
|
+
SingleResult,
|
|
53
|
+
StorageObject,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
__version__ = "0.1.0"
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"create_client",
|
|
60
|
+
"Client",
|
|
61
|
+
"QueryBuilder",
|
|
62
|
+
"InsertBuilder",
|
|
63
|
+
"UpdateBuilder",
|
|
64
|
+
"DeleteBuilder",
|
|
65
|
+
"AuthClient",
|
|
66
|
+
"AuthAdminClient",
|
|
67
|
+
"StorageClient",
|
|
68
|
+
"StorageBucketClient",
|
|
69
|
+
"EmailClient",
|
|
70
|
+
"AuthUser",
|
|
71
|
+
"Session",
|
|
72
|
+
"AuthResponse",
|
|
73
|
+
"AuthChangeEvent",
|
|
74
|
+
"NativeOAuthProvider",
|
|
75
|
+
"Filter",
|
|
76
|
+
"FilterOperator",
|
|
77
|
+
"JoinClause",
|
|
78
|
+
"JoinType",
|
|
79
|
+
"QueryResult",
|
|
80
|
+
"SingleResult",
|
|
81
|
+
"StorageObject",
|
|
82
|
+
"Bucket",
|
|
83
|
+
"RealtimeEvent",
|
|
84
|
+
"RealtimePayload",
|
|
85
|
+
"Cookie",
|
|
86
|
+
"CookieToSet",
|
|
87
|
+
"CookieAdapter",
|
|
88
|
+
"__version__",
|
|
89
|
+
]
|
postbase/_internal.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Internal helpers shared by the sync and async clients.
|
|
2
|
+
|
|
3
|
+
Not part of the public API — names here may change without notice.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import hashlib
|
|
10
|
+
import os
|
|
11
|
+
import secrets
|
|
12
|
+
from typing import Any, Dict, List, Tuple
|
|
13
|
+
|
|
14
|
+
from .types import Filter
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def base64url_encode(raw: bytes) -> str:
|
|
18
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def generate_code_verifier() -> str:
|
|
22
|
+
return base64url_encode(os.urandom(32))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def generate_code_challenge(verifier: str) -> str:
|
|
26
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
27
|
+
return base64url_encode(digest)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def generate_state() -> str:
|
|
31
|
+
return base64url_encode(os.urandom(16))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def random_verifier_pair() -> Tuple[str, str, str]:
|
|
35
|
+
"""Returns (code_verifier, code_challenge, state) for a PKCE OAuth flow."""
|
|
36
|
+
verifier = generate_code_verifier()
|
|
37
|
+
challenge = generate_code_challenge(verifier)
|
|
38
|
+
state = generate_state()
|
|
39
|
+
return verifier, challenge, state
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def secure_token() -> str:
|
|
43
|
+
return secrets.token_urlsafe(24)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ─── Column parsing (SELECT "col AS alias" support) ────────────────────────
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ParsedColumn:
|
|
50
|
+
__slots__ = ("col", "alias")
|
|
51
|
+
|
|
52
|
+
def __init__(self, col: str, alias: str) -> None:
|
|
53
|
+
self.col = col
|
|
54
|
+
self.alias = alias
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_columns(columns: str) -> List[ParsedColumn]:
|
|
58
|
+
parsed: List[ParsedColumn] = []
|
|
59
|
+
for raw in columns.split(","):
|
|
60
|
+
part = raw.strip()
|
|
61
|
+
lower = part.lower()
|
|
62
|
+
as_idx = -1
|
|
63
|
+
i = 0
|
|
64
|
+
while True:
|
|
65
|
+
idx = lower.find(" as ", i)
|
|
66
|
+
if idx == -1:
|
|
67
|
+
break
|
|
68
|
+
as_idx = idx
|
|
69
|
+
i = idx + 1
|
|
70
|
+
if as_idx == -1:
|
|
71
|
+
parsed.append(ParsedColumn(part, part))
|
|
72
|
+
continue
|
|
73
|
+
col = part[:as_idx].strip()
|
|
74
|
+
alias = part[as_idx + 4 :].strip()
|
|
75
|
+
parsed.append(ParsedColumn(col, alias))
|
|
76
|
+
return parsed
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def apply_aliases(rows: List[Dict[str, Any]], parsed: List[ParsedColumn]) -> List[Dict[str, Any]]:
|
|
80
|
+
alias_map: Dict[str, str] = {}
|
|
81
|
+
for p in parsed:
|
|
82
|
+
if p.col != p.alias:
|
|
83
|
+
server_key = p.col.split(".")[-1] if "." in p.col else p.col
|
|
84
|
+
alias_map.setdefault(server_key, p.alias)
|
|
85
|
+
if not alias_map:
|
|
86
|
+
return rows
|
|
87
|
+
out: List[Dict[str, Any]] = []
|
|
88
|
+
for row in rows:
|
|
89
|
+
new_row: Dict[str, Any] = {}
|
|
90
|
+
for k, v in row.items():
|
|
91
|
+
new_row[alias_map.get(k, k)] = v
|
|
92
|
+
out.append(new_row)
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ─── OR filter string parser ────────────────────────────────────────────────
|
|
97
|
+
#
|
|
98
|
+
# Parses Supabase/postbasejs-style filter strings into structured Filter lists.
|
|
99
|
+
# "email.ilike.%foo%,name.ilike.%foo%" -> [Filter, Filter]
|
|
100
|
+
# "status.eq.active,age.gt.18" -> [Filter, Filter]
|
|
101
|
+
# "role.in.(admin,owner)" -> [Filter(op="in", value=[...])]
|
|
102
|
+
#
|
|
103
|
+
# The value is everything after the second dot, so dots in values are safe.
|
|
104
|
+
# Commas inside balanced parentheses (for the `in` operator) are not split on.
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_or_filter_string(filter_str: str) -> List[Filter]:
|
|
108
|
+
segments: List[str] = []
|
|
109
|
+
depth = 0
|
|
110
|
+
start = 0
|
|
111
|
+
for i, ch in enumerate(filter_str):
|
|
112
|
+
if ch == "(":
|
|
113
|
+
depth += 1
|
|
114
|
+
elif ch == ")":
|
|
115
|
+
depth -= 1
|
|
116
|
+
elif ch == "," and depth == 0:
|
|
117
|
+
segments.append(filter_str[start:i].strip())
|
|
118
|
+
start = i + 1
|
|
119
|
+
segments.append(filter_str[start:].strip())
|
|
120
|
+
|
|
121
|
+
filters: List[Filter] = []
|
|
122
|
+
for seg in segments:
|
|
123
|
+
if not seg:
|
|
124
|
+
continue
|
|
125
|
+
first_dot = seg.find(".")
|
|
126
|
+
second_dot = seg.find(".", first_dot + 1)
|
|
127
|
+
if first_dot == -1 or second_dot == -1:
|
|
128
|
+
filters.append(Filter(column=seg, operator="eq", value=None))
|
|
129
|
+
continue
|
|
130
|
+
column = seg[:first_dot]
|
|
131
|
+
operator = seg[first_dot + 1 : second_dot]
|
|
132
|
+
raw_value = seg[second_dot + 1 :]
|
|
133
|
+
|
|
134
|
+
value: Any = raw_value
|
|
135
|
+
if operator == "in":
|
|
136
|
+
inner = raw_value
|
|
137
|
+
if inner.startswith("("):
|
|
138
|
+
inner = inner[1:]
|
|
139
|
+
if inner.endswith(")"):
|
|
140
|
+
inner = inner[:-1]
|
|
141
|
+
value = [v.strip() for v in inner.split(",")]
|
|
142
|
+
elif raw_value == "true":
|
|
143
|
+
value = True
|
|
144
|
+
elif raw_value == "false":
|
|
145
|
+
value = False
|
|
146
|
+
elif raw_value == "null":
|
|
147
|
+
value = None
|
|
148
|
+
|
|
149
|
+
filters.append(Filter(column=column, operator=operator, value=value)) # type: ignore[arg-type]
|
|
150
|
+
return filters
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def filter_to_dict(f: Filter) -> Dict[str, Any]:
|
|
154
|
+
return {"column": f.column, "operator": f.operator, "value": f.value}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def filters_to_dicts(filters: List[Filter]) -> List[Dict[str, Any]]:
|
|
158
|
+
return [filter_to_dict(f) for f in filters]
|
postbase/_query_state.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Query state shared by sync and async query builders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field, replace
|
|
6
|
+
from typing import Any, Dict, List, Literal, Optional, Union
|
|
7
|
+
|
|
8
|
+
from .types import Filter, JoinClause, OrderBy
|
|
9
|
+
|
|
10
|
+
Operation = Literal["select", "insert", "update", "delete", "upsert"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class QueryState:
|
|
15
|
+
base_url: str
|
|
16
|
+
api_key: str
|
|
17
|
+
table: str
|
|
18
|
+
columns: str = "*"
|
|
19
|
+
select_count: Optional[str] = None
|
|
20
|
+
select_head: Optional[bool] = None
|
|
21
|
+
filters: List[Filter] = field(default_factory=list)
|
|
22
|
+
or_filters: List[List[Filter]] = field(default_factory=list)
|
|
23
|
+
not_filters: List[Dict[str, Any]] = field(default_factory=list)
|
|
24
|
+
order_by: List[OrderBy] = field(default_factory=list)
|
|
25
|
+
joins: List[JoinClause] = field(default_factory=list)
|
|
26
|
+
limit_: Optional[int] = None
|
|
27
|
+
offset_: Optional[int] = None
|
|
28
|
+
range_: Optional[Dict[str, int]] = None
|
|
29
|
+
operation: Operation = "select"
|
|
30
|
+
insert_data: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]] = None
|
|
31
|
+
upsert_on_conflict: Optional[str] = None
|
|
32
|
+
update_data: Optional[Dict[str, Any]] = None
|
|
33
|
+
returning: Optional[str] = None
|
|
34
|
+
extra_headers: Dict[str, str] = field(default_factory=dict)
|
|
35
|
+
session_token: Optional[str] = None
|
|
36
|
+
|
|
37
|
+
def evolve(self, **changes: Any) -> QueryState:
|
|
38
|
+
return replace(self, **changes)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_request_body(state: QueryState) -> Dict[str, Any]:
|
|
42
|
+
body: Dict[str, Any] = {
|
|
43
|
+
"operation": "upsert" if state.operation == "upsert" else state.operation,
|
|
44
|
+
"table": state.table,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
parsed_columns = None
|
|
48
|
+
if state.operation == "select":
|
|
49
|
+
if state.columns and state.columns != "*":
|
|
50
|
+
from ._internal import parse_columns
|
|
51
|
+
|
|
52
|
+
parsed_columns = parse_columns(state.columns)
|
|
53
|
+
body["columns"] = [p.col for p in parsed_columns]
|
|
54
|
+
if state.select_count is not None:
|
|
55
|
+
body["count"] = state.select_count
|
|
56
|
+
if state.select_head is not None:
|
|
57
|
+
body["head"] = state.select_head
|
|
58
|
+
if state.joins:
|
|
59
|
+
body["joins"] = [
|
|
60
|
+
{"table": j.table, "on": j.on, **({"type": j.type} if j.type else {})}
|
|
61
|
+
for j in state.joins
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
if state.operation in ("select", "update", "delete"):
|
|
65
|
+
from ._internal import filters_to_dicts
|
|
66
|
+
|
|
67
|
+
if state.filters:
|
|
68
|
+
body["filters"] = filters_to_dicts(state.filters)
|
|
69
|
+
if state.or_filters:
|
|
70
|
+
body["orFilters"] = [filters_to_dicts(group) for group in state.or_filters]
|
|
71
|
+
if state.not_filters:
|
|
72
|
+
body["notFilters"] = state.not_filters
|
|
73
|
+
|
|
74
|
+
if state.operation in ("insert", "upsert"):
|
|
75
|
+
body["data"] = state.insert_data
|
|
76
|
+
if state.upsert_on_conflict:
|
|
77
|
+
body["onConflict"] = state.upsert_on_conflict
|
|
78
|
+
|
|
79
|
+
if state.operation == "update":
|
|
80
|
+
body["data"] = state.update_data
|
|
81
|
+
|
|
82
|
+
if state.order_by:
|
|
83
|
+
body["order"] = [
|
|
84
|
+
{
|
|
85
|
+
"column": o.column,
|
|
86
|
+
**({"ascending": o.ascending} if o.ascending is not None else {}),
|
|
87
|
+
**({"nullsFirst": o.nulls_first} if o.nulls_first is not None else {}),
|
|
88
|
+
}
|
|
89
|
+
for o in state.order_by
|
|
90
|
+
]
|
|
91
|
+
if state.limit_ is not None:
|
|
92
|
+
body["limit"] = state.limit_
|
|
93
|
+
if state.offset_ is not None:
|
|
94
|
+
body["offset"] = state.offset_
|
|
95
|
+
if state.range_ is not None:
|
|
96
|
+
body["range"] = state.range_
|
|
97
|
+
if state.returning:
|
|
98
|
+
body["returning"] = state.returning
|
|
99
|
+
|
|
100
|
+
return body
|
postbase/aio/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""postbase.aio — asynchronous Postbase client (httpx.AsyncClient based).
|
|
2
|
+
|
|
3
|
+
Use this in async web frameworks (FastAPI, Starlette, aiohttp servers) or
|
|
4
|
+
async scripts. See `postbase` (the top-level package) for the sync client.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .auth import AsyncAuthAdminClient, AsyncAuthClient
|
|
8
|
+
from .client import AsyncClient, create_async_client
|
|
9
|
+
from .email import AsyncEmailClient
|
|
10
|
+
from .query import AsyncDeleteBuilder, AsyncInsertBuilder, AsyncQueryBuilder, AsyncUpdateBuilder
|
|
11
|
+
from .storage import AsyncStorageBucketClient, AsyncStorageClient
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"create_async_client",
|
|
15
|
+
"AsyncClient",
|
|
16
|
+
"AsyncQueryBuilder",
|
|
17
|
+
"AsyncInsertBuilder",
|
|
18
|
+
"AsyncUpdateBuilder",
|
|
19
|
+
"AsyncDeleteBuilder",
|
|
20
|
+
"AsyncAuthClient",
|
|
21
|
+
"AsyncAuthAdminClient",
|
|
22
|
+
"AsyncStorageClient",
|
|
23
|
+
"AsyncStorageBucketClient",
|
|
24
|
+
"AsyncEmailClient",
|
|
25
|
+
]
|