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.
- trailbase-0.1.0/PKG-INFO +23 -0
- trailbase-0.1.0/README.md +8 -0
- trailbase-0.1.0/pyproject.toml +28 -0
- trailbase-0.1.0/trailbase/__init__.py +393 -0
trailbase-0.1.0/PKG-INFO
ADDED
|
@@ -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__)
|