trailbase 0.1.0__tar.gz

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,23 @@
1
+ Metadata-Version: 2.1
2
+ Name: trailbase
3
+ Version: 0.1.0
4
+ Summary:
5
+ Author: TrailBase
6
+ Author-email: contact@trailbase.io
7
+ Requires-Python: >=3.12,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Requires-Dist: cryptography (>=43.0.3,<44.0.0)
11
+ Requires-Dist: httpx (>=0.27.2,<0.28.0)
12
+ Requires-Dist: pyjwt (>=2.10.0,<3.0.0)
13
+ Description-Content-Type: text/markdown
14
+
15
+ # TrailBase Client for Python
16
+
17
+ TrailBase is a [blazingly](https://trailbase.io/reference/benchmarks/) fast,
18
+ single-file, open-source application server with type-safe APIs, built-in
19
+ JS/ES6/TS Runtime, Auth, and Admin UI built on Rust+SQLite+V8.
20
+
21
+ For more context, documentation, and an online demo, check out our website
22
+ [trailbase.io](https://trailbase.io).
23
+
@@ -0,0 +1,8 @@
1
+ # TrailBase Client for Python
2
+
3
+ TrailBase is a [blazingly](https://trailbase.io/reference/benchmarks/) fast,
4
+ single-file, open-source application server with type-safe APIs, built-in
5
+ JS/ES6/TS Runtime, Auth, and Admin UI built on Rust+SQLite+V8.
6
+
7
+ For more context, documentation, and an online demo, check out our website
8
+ [trailbase.io](https://trailbase.io).
@@ -0,0 +1,28 @@
1
+ [tool.poetry]
2
+ name = "trailbase"
3
+ version = "0.1.0"
4
+ description = ""
5
+ authors = ["TrailBase <contact@trailbase.io>"]
6
+ readme = "README.md"
7
+
8
+ [tool.poetry.dependencies]
9
+ python = "^3.12"
10
+ httpx = "^0.27.2"
11
+ pyjwt = "^2.10.0"
12
+ cryptography = "^43.0.3"
13
+
14
+ [tool.poetry.group.dev.dependencies]
15
+ pytest = "^8.3.3"
16
+ black = "^24.10.0"
17
+ pyright = "^1.1.389"
18
+
19
+ [build-system]
20
+ requires = ["poetry-core"]
21
+ build-backend = "poetry.core.masonry.api"
22
+
23
+ [tool.black]
24
+ line-length = 108
25
+
26
+ [tool.pyright]
27
+ venvPath = "."
28
+ venv = ".venv"
@@ -0,0 +1,393 @@
1
+ __title__ = "trailbase"
2
+ __description__ = "TrailBase client SDK for python."
3
+ __version__ = "0.1.0"
4
+
5
+ import httpx
6
+ import jwt
7
+ import logging
8
+
9
+ from time import time
10
+ from typing import TypeAlias, Any
11
+
12
+ JSON: TypeAlias = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None
13
+
14
+
15
+ class RecordId:
16
+ id: str | int
17
+
18
+ def __init__(self, id: str | int):
19
+ self.id = id
20
+
21
+ @staticmethod
22
+ def fromJson(json: dict[str, "JSON"]) -> "RecordId":
23
+ id = json["id"]
24
+ assert isinstance(id, str) or isinstance(id, int)
25
+ return RecordId(id)
26
+
27
+ def __repr__(self) -> str:
28
+ return f"{self.id}"
29
+
30
+
31
+ class User:
32
+ id: str
33
+ email: str
34
+
35
+ def __init__(self, id: str, email: str) -> None:
36
+ self.id = id
37
+ self.email = email
38
+
39
+ @staticmethod
40
+ def fromJson(json: dict[str, "JSON"]) -> "User":
41
+ sub = json["sub"]
42
+ assert isinstance(sub, str)
43
+ email = json["email"]
44
+ assert isinstance(email, str)
45
+
46
+ return User(sub, email)
47
+
48
+ def toJson(self) -> dict[str, str]:
49
+ return {
50
+ "sub": self.id,
51
+ "email": self.email,
52
+ }
53
+
54
+
55
+ class Tokens:
56
+ auth: str
57
+ refresh: str | None
58
+ csrf: str | None
59
+
60
+ def __init__(self, auth: str, refresh: str | None, csrf: str | None) -> None:
61
+ self.auth = auth
62
+ self.refresh = refresh
63
+ self.csrf = csrf
64
+
65
+ @staticmethod
66
+ def fromJson(json: dict[str, "JSON"]) -> "Tokens":
67
+ auth = json["auth_token"]
68
+ assert isinstance(auth, str)
69
+ refresh = json["refresh_token"]
70
+ assert isinstance(refresh, str)
71
+ csrf = json["csrf_token"]
72
+ assert isinstance(csrf, str)
73
+
74
+ return Tokens(auth, refresh, csrf)
75
+
76
+ def toJson(self) -> dict[str, str | None]:
77
+ return {
78
+ "auth_token": self.auth,
79
+ "refresh_token": self.refresh,
80
+ "csrf_token": self.csrf,
81
+ }
82
+
83
+ def isValid(self) -> bool:
84
+ return jwt.decode(self.auth, algorithms=["EdDSA"], options={"verify_signature": False}) != None
85
+
86
+
87
+ class JwtToken:
88
+ sub: str
89
+ iat: int
90
+ exp: int
91
+ email: str
92
+ csrfToken: str
93
+
94
+ def __init__(self, sub: str, iat: int, exp: int, email: str, csrfToken: str) -> None:
95
+ self.sub = sub
96
+ self.iat = iat
97
+ self.exp = exp
98
+ self.email = email
99
+ self.csrfToken = csrfToken
100
+
101
+ @staticmethod
102
+ def fromJson(json: dict[str, "JSON"]) -> "JwtToken":
103
+ sub = json["sub"]
104
+ assert isinstance(sub, str)
105
+ iat = json["iat"]
106
+ assert isinstance(iat, int)
107
+ exp = json["exp"]
108
+ assert isinstance(exp, int)
109
+ email = json["email"]
110
+ assert isinstance(email, str)
111
+ csrfToken = json["csrf_token"]
112
+ assert isinstance(csrfToken, str)
113
+
114
+ return JwtToken(sub, iat, exp, email, csrfToken)
115
+
116
+
117
+ class TokenState:
118
+ state: tuple[Tokens, JwtToken] | None
119
+ headers: dict[str, str]
120
+
121
+ def __init__(self, state: tuple[Tokens, JwtToken] | None, headers: dict[str, str]) -> None:
122
+ self.state = state
123
+ self.headers = headers
124
+
125
+ @staticmethod
126
+ def build(tokens: Tokens | None) -> "TokenState":
127
+ decoded = (
128
+ jwt.decode(tokens.auth, algorithms=["EdDSA"], options={"verify_signature": False})
129
+ if tokens != None
130
+ else None
131
+ )
132
+
133
+ if decoded == None or tokens == None:
134
+ return TokenState(None, TokenState.buildHeaders(tokens))
135
+
136
+ return TokenState(
137
+ (tokens, JwtToken.fromJson(decoded)),
138
+ TokenState.buildHeaders(tokens),
139
+ )
140
+
141
+ @staticmethod
142
+ def buildHeaders(tokens: Tokens | None) -> dict[str, str]:
143
+ base = {
144
+ "Content-Type": "application/json",
145
+ }
146
+
147
+ if tokens != None:
148
+ base["Authorization"] = f"Bearer {tokens.auth}"
149
+
150
+ refresh = tokens.refresh
151
+ if refresh != None:
152
+ base["Refresh-Token"] = refresh
153
+
154
+ csrf = tokens.csrf
155
+ if csrf != None:
156
+ base["CSRF-Token"] = csrf
157
+
158
+ return base
159
+
160
+
161
+ class ThinClient:
162
+ http_client: httpx.Client
163
+ site: str
164
+
165
+ def __init__(self, site: str, http_client: httpx.Client | None = None) -> None:
166
+ self.site = site
167
+ self.http_client = http_client or httpx.Client()
168
+
169
+ def fetch(
170
+ self,
171
+ path: str,
172
+ tokenState: TokenState,
173
+ method: str | None = "GET",
174
+ data: dict[str, Any] | None = None,
175
+ queryParams: dict[str, str] | None = None,
176
+ ) -> httpx.Response:
177
+ assert not path.startswith("/")
178
+
179
+ logger.debug(f"headers: {data} {tokenState.headers}")
180
+
181
+ return self.http_client.request(
182
+ method=method or "GET",
183
+ url=f"{self.site}/{path}",
184
+ json=data,
185
+ headers=tokenState.headers,
186
+ params=queryParams,
187
+ )
188
+
189
+
190
+ class Client:
191
+ _authApi: str = "api/auth/v1"
192
+
193
+ _client: ThinClient
194
+ _site: str
195
+ _tokenState: TokenState
196
+
197
+ def __init__(
198
+ self,
199
+ site: str,
200
+ tokens: Tokens | None,
201
+ http_client: httpx.Client | None = None,
202
+ ) -> None:
203
+ self._client = ThinClient(site, http_client)
204
+ self._site = site
205
+ self._tokenState = TokenState.build(tokens)
206
+
207
+ def tokens(self) -> Tokens | None:
208
+ state = self._tokenState.state
209
+ return state[0] if state else None
210
+
211
+ def user(self) -> User | None:
212
+ tokens = self.tokens()
213
+ if tokens != None:
214
+ return User.fromJson(
215
+ jwt.decode(tokens.auth, algorithms=["EdDSA"], options={"verify_signature": False})
216
+ )
217
+
218
+ def site(self) -> str:
219
+ return self._site
220
+
221
+ def login(self, email: str, password: str) -> Tokens:
222
+ response = self.fetch(
223
+ f"{self._authApi}/login",
224
+ method="POST",
225
+ data={
226
+ "email": email,
227
+ "password": password,
228
+ },
229
+ )
230
+
231
+ json = response.json()
232
+ tokens = Tokens(
233
+ json["auth_token"],
234
+ json["refresh_token"],
235
+ json["csrf_token"],
236
+ )
237
+
238
+ self._updateTokens(tokens)
239
+ return tokens
240
+
241
+ def logout(self) -> None:
242
+ state = self._tokenState.state
243
+ refreshToken = state[0].refresh if state else None
244
+ try:
245
+ if refreshToken != None:
246
+ self.fetch(
247
+ f"{self._authApi}/logout",
248
+ method="POST",
249
+ data={
250
+ "refresh_token": refreshToken,
251
+ },
252
+ )
253
+ else:
254
+ self.fetch(f"{self._authApi}/logout")
255
+ except:
256
+ pass
257
+
258
+ self._updateTokens(None)
259
+
260
+ def records(self, name: str) -> "RecordApi":
261
+ return RecordApi(name, self)
262
+
263
+ def _updateTokens(self, tokens: Tokens | None):
264
+ state = TokenState.build(tokens)
265
+
266
+ self._tokenState = state
267
+
268
+ state = state.state
269
+ if state != None:
270
+ claims = state[1]
271
+ now = int(time())
272
+ if claims.exp < now:
273
+ logger.warn("Token expired")
274
+
275
+ return state
276
+
277
+ @staticmethod
278
+ def _shouldRefresh(tokenState: TokenState) -> str | None:
279
+ state = tokenState.state
280
+ now = int(time())
281
+ if state != None and state[1].exp - 60 < now:
282
+ return state[0].refresh
283
+ return None
284
+
285
+ def _refreshTokensImpl(self, refreshToken: str) -> TokenState:
286
+ response = self._client.fetch(
287
+ f"{self._authApi}/refresh",
288
+ self._tokenState,
289
+ method="POST",
290
+ data={
291
+ "refresh_token": refreshToken,
292
+ },
293
+ )
294
+
295
+ json = response.json()
296
+ return TokenState.build(
297
+ Tokens(
298
+ json["auth_token"],
299
+ refreshToken,
300
+ json["csrf_token"],
301
+ )
302
+ )
303
+
304
+ def fetch(
305
+ self,
306
+ path: str,
307
+ method: str | None = "GET",
308
+ data: dict[str, Any] | None = None,
309
+ queryParams: dict[str, str] | None = None,
310
+ ) -> httpx.Response:
311
+ tokenState = self._tokenState
312
+ refreshToken = Client._shouldRefresh(tokenState)
313
+ if refreshToken != None:
314
+ tokenState = self._tokenState = self._refreshTokensImpl(refreshToken)
315
+
316
+ response = self._client.fetch(path, tokenState, method=method, data=data, queryParams=queryParams)
317
+
318
+ return response
319
+
320
+
321
+ class RecordApi:
322
+ _recordApi: str = "api/records/v1"
323
+
324
+ _name: str
325
+ _client: Client
326
+
327
+ def __init__(self, name: str, client: Client) -> None:
328
+ self._name = name
329
+ self._client = client
330
+
331
+ def list(
332
+ self,
333
+ order: list[str] | None = None,
334
+ filters: list[str] | None = None,
335
+ cursor: str | None = None,
336
+ limit: int | None = None,
337
+ ) -> list[dict[str, object]]:
338
+ params: dict[str, str] = {}
339
+
340
+ if cursor != None:
341
+ params["cursor"] = cursor
342
+
343
+ if limit != None:
344
+ params["limit"] = str(limit)
345
+
346
+ if order != None:
347
+ params["order"] = ",".join(order)
348
+
349
+ if filters != None:
350
+ for filter in filters:
351
+ (nameOp, value) = filter.split("=", 1)
352
+ if value == None:
353
+ raise Exception(f"Filter '{filter}' does not match: 'name[op]=value'")
354
+
355
+ params[nameOp] = value
356
+
357
+ response = self._client.fetch(f"{self._recordApi}/{self._name}", queryParams=params)
358
+ return response.json()
359
+
360
+ def read(self, recordId: RecordId | str | int) -> dict[str, object]:
361
+ response = self._client.fetch(f"{self._recordApi}/{self._name}/{repr(recordId)}")
362
+ return response.json()
363
+
364
+ def create(self, record: dict[str, object]) -> RecordId:
365
+ response = self._client.fetch(
366
+ f"{RecordApi._recordApi}/{self._name}",
367
+ method="POST",
368
+ data=record,
369
+ )
370
+ if response.status_code > 200:
371
+ raise Exception(f"{response}")
372
+
373
+ return RecordId.fromJson(response.json())
374
+
375
+ def update(self, recordId: RecordId | str | int, record: dict[str, object]) -> None:
376
+ response = self._client.fetch(
377
+ f"{RecordApi._recordApi}/{self._name}/{repr(recordId)}",
378
+ method="PATCH",
379
+ data=record,
380
+ )
381
+ if response.status_code > 200:
382
+ raise Exception(f"{response}")
383
+
384
+ def delete(self, recordId: RecordId | str | int) -> None:
385
+ response = self._client.fetch(
386
+ f"{RecordApi._recordApi}/{self._name}/{repr(recordId)}",
387
+ method="DELETE",
388
+ )
389
+ if response.status_code > 200:
390
+ raise Exception(f"{response}")
391
+
392
+
393
+ logger = logging.getLogger(__name__)