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 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]
@@ -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
@@ -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
+ ]