mirror of
https://github.com/trailbaseio/trailbase.git
synced 2025-12-30 14:19:43 -06:00
Add fist version of a TrailBase Python client including tests and CI setup
This commit is contained in:
7
.github/workflows/test.yml
vendored
7
.github/workflows/test.yml
vendored
@@ -36,5 +36,10 @@ jobs:
|
||||
with:
|
||||
toolchain: stable
|
||||
default: true
|
||||
- uses: actions/setup-python@v3
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- name: Poetry install
|
||||
run: |
|
||||
pipx install poetry && poetry -C client/trailbase-py install
|
||||
- uses: pre-commit/action@v3.0.1
|
||||
|
||||
@@ -79,7 +79,7 @@ repos:
|
||||
|
||||
- id: build_website
|
||||
name: Build Website
|
||||
entry: sh -c 'cd docs && pnpm i && pnpm build'
|
||||
entry: sh -c 'cd docs && pnpm build'
|
||||
language: system
|
||||
types: [file]
|
||||
files: .*\.(js|mjs|cjs|ts|jsx|tsx|astro)$
|
||||
@@ -126,3 +126,28 @@ repos:
|
||||
types: [file]
|
||||
files: .*\.(cs|csproj)$
|
||||
pass_filenames: false
|
||||
|
||||
### Python client
|
||||
- id: python_format
|
||||
name: Python format
|
||||
entry: poetry -C client/trailbase-py run black client/trailbase-py --check
|
||||
language: system
|
||||
types: [file]
|
||||
files: .*\.(py)$
|
||||
pass_filenames: false
|
||||
|
||||
- id: python_check
|
||||
name: Python check
|
||||
entry: poetry -C client/trailbase-py run pyright client/trailbase-py
|
||||
language: system
|
||||
types: [file]
|
||||
files: .*\.(py)$
|
||||
pass_filenames: false
|
||||
|
||||
- id: python_test
|
||||
name: Python test
|
||||
entry: poetry -C client/trailbase-py run pytest
|
||||
language: system
|
||||
types: [file]
|
||||
files: .*\.(py)$
|
||||
pass_filenames: false
|
||||
|
||||
6
Makefile
6
Makefile
@@ -8,13 +8,15 @@ format:
|
||||
cargo +nightly fmt; \
|
||||
dart format client/trailbase-dart/ examples/blog/flutter/; \
|
||||
txtpbfmt `find . -regex ".*.textproto"`; \
|
||||
dotnet format client/trailbase-dotnet
|
||||
dotnet format client/trailbase-dotnet; \
|
||||
poetry -C client/trailbase-py run black client/trailbase-py
|
||||
|
||||
check:
|
||||
pnpm -r check; \
|
||||
cargo clippy --workspace --no-deps; \
|
||||
dart analyze client/trailbase-dart examples/blog/flutter; \
|
||||
dotnet format client/trailbase-dotnet --verify-no-changes
|
||||
dotnet format client/trailbase-dotnet --verify-no-changes; \
|
||||
poetry -C client/trailbase-py run pyright
|
||||
|
||||
docker:
|
||||
docker build . -t trailbase/trailbase
|
||||
|
||||
@@ -64,6 +64,7 @@ Moreover, client packages and containers are available via:
|
||||
- [JavaScript/Typescript client](https://www.npmjs.com/package/trailbase)
|
||||
- [Dart/Flutter client](https://pub.dev/packages/trailbase)
|
||||
- [C#/.Net](https://www.nuget.org/packages/TrailBase/)
|
||||
- [Python](https://github.com/trailbaseio/trailbase/tree/main/client/trailbase-py)
|
||||
|
||||
## Running
|
||||
|
||||
|
||||
@@ -149,6 +149,14 @@ Future<void> main() async {
|
||||
expect(RecordId.uuid(record.id) == ids[0], isTrue);
|
||||
|
||||
expect(record.textNotNull, messages[0]);
|
||||
|
||||
final updatedMessage = 'dart client updated test 0: ${now}';
|
||||
await api.update(ids[0], {'text_not_null': updatedMessage});
|
||||
final updatedRecord = SimpleStrict.fromJson(await api.read(ids[0]));
|
||||
expect(updatedRecord.textNotNull, updatedMessage);
|
||||
|
||||
await api.delete(ids[0]);
|
||||
expect(() async => await api.read(ids[0]), throwsException);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# TrailBase client library for .NET and MAUI
|
||||
# TrailBase Client Library for .NET and MAUI
|
||||
|
||||
TrailBase is a [blazingly](https://trailbase.io/reference/benchmarks/) fast,
|
||||
single-file, open-source application server with type-safe APIs, built-in
|
||||
|
||||
2
client/trailbase-py/.gitignore
vendored
Normal file
2
client/trailbase-py/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
**/__pycache__/
|
||||
poetry.lock
|
||||
8
client/trailbase-py/README.md
Normal file
8
client/trailbase-py/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# TrailBase Client for Python
|
||||
|
||||
TrailBase is a [blazingly](https://trailbase.io/reference/benchmarks/) fast,
|
||||
single-file, open-source application server with type-safe APIs, built-in
|
||||
JS/ES6/TS Runtime, Auth, and Admin UI built on Rust+SQLite+V8.
|
||||
|
||||
For more context, documentation, and an online demo, check out our website
|
||||
[trailbase.io](https://trailbase.io).
|
||||
3
client/trailbase-py/poetry.toml
Normal file
3
client/trailbase-py/poetry.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[virtualenvs]
|
||||
in-project = true
|
||||
create = true
|
||||
28
client/trailbase-py/pyproject.toml
Normal file
28
client/trailbase-py/pyproject.toml
Normal file
@@ -0,0 +1,28 @@
|
||||
[tool.poetry]
|
||||
name = "trailbase"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = ["TrailBase <contact@trailbase.io>"]
|
||||
readme = "README.md"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.12"
|
||||
httpx = "^0.27.2"
|
||||
pyjwt = "^2.10.0"
|
||||
cryptography = "^43.0.3"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = "^8.3.3"
|
||||
black = "^24.10.0"
|
||||
pyright = "^1.1.389"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.black]
|
||||
line-length = 108
|
||||
|
||||
[tool.pyright]
|
||||
venvPath = "."
|
||||
venv = ".venv"
|
||||
0
client/trailbase-py/tests/__init__.py
Normal file
0
client/trailbase-py/tests/__init__.py
Normal file
158
client/trailbase-py/tests/test_client.py
Normal file
158
client/trailbase-py/tests/test_client.py
Normal file
@@ -0,0 +1,158 @@
|
||||
from re import sub
|
||||
from trailbase import Client, RecordId
|
||||
|
||||
import httpx
|
||||
import logging
|
||||
import os
|
||||
import pytest
|
||||
import subprocess
|
||||
|
||||
from time import time, sleep
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
port = 4007
|
||||
address = f"127.0.0.1:{port}"
|
||||
site = f"http://{address}"
|
||||
|
||||
|
||||
class TrailBaseFixture:
|
||||
process: None | subprocess.Popen
|
||||
|
||||
def __init__(self) -> None:
|
||||
cwd = os.getcwd()
|
||||
traildepot = "../testfixture" if cwd.endswith("trailbase-py") else "client/testfixture"
|
||||
|
||||
logger.info("Building TrailBase")
|
||||
build = subprocess.run(["cargo", "build"])
|
||||
assert build.returncode == 0
|
||||
|
||||
logger.info("Starting TrailBase")
|
||||
self.process = subprocess.Popen(
|
||||
[
|
||||
"cargo",
|
||||
"run",
|
||||
"--",
|
||||
"--data-dir",
|
||||
traildepot,
|
||||
"run",
|
||||
"-a",
|
||||
address,
|
||||
"--js-runtime-threads",
|
||||
"1",
|
||||
]
|
||||
)
|
||||
|
||||
client = httpx.Client()
|
||||
for _ in range(100):
|
||||
try:
|
||||
response = client.get(f"http://{address}/api/healthcheck")
|
||||
if response.status_code == 200:
|
||||
return
|
||||
except:
|
||||
pass
|
||||
|
||||
sleep(0.5)
|
||||
|
||||
logger.error("Failed ot start TrailBase")
|
||||
|
||||
def isUp(self) -> bool:
|
||||
p = self.process
|
||||
return p != None and p.returncode == None
|
||||
|
||||
def shutdown(self) -> None:
|
||||
p = self.process
|
||||
if p != None:
|
||||
p.send_signal(9)
|
||||
p.wait()
|
||||
assert isinstance(p.returncode, int)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def trailbase():
|
||||
fixture = TrailBaseFixture()
|
||||
yield fixture
|
||||
fixture.shutdown()
|
||||
|
||||
|
||||
def connect() -> Client:
|
||||
client = Client(site, tokens=None)
|
||||
client.login("admin@localhost", "secret")
|
||||
return client
|
||||
|
||||
|
||||
def test_client_login(trailbase: TrailBaseFixture):
|
||||
assert trailbase.isUp()
|
||||
|
||||
client = connect()
|
||||
assert client.site() == site
|
||||
|
||||
tokens = client.tokens()
|
||||
assert tokens != None and tokens.isValid()
|
||||
|
||||
user = client.user()
|
||||
assert user != None and user.id != ""
|
||||
assert user != None and user.email == "admin@localhost"
|
||||
|
||||
client.logout()
|
||||
assert client.tokens() == None
|
||||
|
||||
|
||||
def test_records(trailbase: TrailBaseFixture):
|
||||
assert trailbase.isUp()
|
||||
|
||||
client = connect()
|
||||
api = client.records("simple_strict_table")
|
||||
|
||||
now = int(time())
|
||||
messages = [
|
||||
f"dart client test 0: {now}",
|
||||
f"dart client test 1: {now}",
|
||||
]
|
||||
ids: list[RecordId] = []
|
||||
for msg in messages:
|
||||
ids.append(api.create({"text_not_null": msg}))
|
||||
|
||||
if True:
|
||||
records = api.list(
|
||||
filters=[f"text_not_null={messages[0]}"],
|
||||
)
|
||||
assert len(records) == 1
|
||||
assert records[0]["text_not_null"] == messages[0]
|
||||
|
||||
if True:
|
||||
recordsAsc = api.list(
|
||||
order=["+text_not_null"],
|
||||
filters=[f"text_not_null[like]=%{now}"],
|
||||
)
|
||||
|
||||
assert [el["text_not_null"] for el in recordsAsc] == messages
|
||||
|
||||
recordsDesc = api.list(
|
||||
order=["-text_not_null"],
|
||||
filters=[f"text_not_null[like]=%{now}"],
|
||||
)
|
||||
|
||||
assert [el["text_not_null"] for el in recordsDesc] == list(reversed(messages))
|
||||
|
||||
if True:
|
||||
record = api.read(ids[0])
|
||||
assert record["text_not_null"] == messages[0]
|
||||
|
||||
record = api.read(ids[1])
|
||||
assert record["text_not_null"] == messages[1]
|
||||
|
||||
if True:
|
||||
updatedMessage = f"dart client updated test 0: {now}"
|
||||
api.update(ids[0], {"text_not_null": updatedMessage})
|
||||
record = api.read(ids[0])
|
||||
assert record["text_not_null"] == updatedMessage
|
||||
|
||||
if True:
|
||||
api.delete(ids[0])
|
||||
|
||||
with pytest.raises(Exception):
|
||||
api.read(ids[0])
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
393
client/trailbase-py/trailbase/__init__.py
Normal file
393
client/trailbase-py/trailbase/__init__.py
Normal file
@@ -0,0 +1,393 @@
|
||||
__title__ = "trailbase"
|
||||
__description__ = "TrailBase client SDK for python."
|
||||
__version__ = "0.1.0"
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import logging
|
||||
|
||||
from time import time
|
||||
from typing import TypeAlias, Any
|
||||
|
||||
JSON: TypeAlias = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None
|
||||
|
||||
|
||||
class RecordId:
|
||||
id: str | int
|
||||
|
||||
def __init__(self, id: str | int):
|
||||
self.id = id
|
||||
|
||||
@staticmethod
|
||||
def fromJson(json: dict[str, "JSON"]) -> "RecordId":
|
||||
id = json["id"]
|
||||
assert isinstance(id, str) or isinstance(id, int)
|
||||
return RecordId(id)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.id}"
|
||||
|
||||
|
||||
class User:
|
||||
id: str
|
||||
email: str
|
||||
|
||||
def __init__(self, id: str, email: str) -> None:
|
||||
self.id = id
|
||||
self.email = email
|
||||
|
||||
@staticmethod
|
||||
def fromJson(json: dict[str, "JSON"]) -> "User":
|
||||
sub = json["sub"]
|
||||
assert isinstance(sub, str)
|
||||
email = json["email"]
|
||||
assert isinstance(email, str)
|
||||
|
||||
return User(sub, email)
|
||||
|
||||
def toJson(self) -> dict[str, str]:
|
||||
return {
|
||||
"sub": self.id,
|
||||
"email": self.email,
|
||||
}
|
||||
|
||||
|
||||
class Tokens:
|
||||
auth: str
|
||||
refresh: str | None
|
||||
csrf: str | None
|
||||
|
||||
def __init__(self, auth: str, refresh: str | None, csrf: str | None) -> None:
|
||||
self.auth = auth
|
||||
self.refresh = refresh
|
||||
self.csrf = csrf
|
||||
|
||||
@staticmethod
|
||||
def fromJson(json: dict[str, "JSON"]) -> "Tokens":
|
||||
auth = json["auth_token"]
|
||||
assert isinstance(auth, str)
|
||||
refresh = json["refresh_token"]
|
||||
assert isinstance(refresh, str)
|
||||
csrf = json["csrf_token"]
|
||||
assert isinstance(csrf, str)
|
||||
|
||||
return Tokens(auth, refresh, csrf)
|
||||
|
||||
def toJson(self) -> dict[str, str | None]:
|
||||
return {
|
||||
"auth_token": self.auth,
|
||||
"refresh_token": self.refresh,
|
||||
"csrf_token": self.csrf,
|
||||
}
|
||||
|
||||
def isValid(self) -> bool:
|
||||
return jwt.decode(self.auth, algorithms=["EdDSA"], options={"verify_signature": False}) != None
|
||||
|
||||
|
||||
class JwtToken:
|
||||
sub: str
|
||||
iat: int
|
||||
exp: int
|
||||
email: str
|
||||
csrfToken: str
|
||||
|
||||
def __init__(self, sub: str, iat: int, exp: int, email: str, csrfToken: str) -> None:
|
||||
self.sub = sub
|
||||
self.iat = iat
|
||||
self.exp = exp
|
||||
self.email = email
|
||||
self.csrfToken = csrfToken
|
||||
|
||||
@staticmethod
|
||||
def fromJson(json: dict[str, "JSON"]) -> "JwtToken":
|
||||
sub = json["sub"]
|
||||
assert isinstance(sub, str)
|
||||
iat = json["iat"]
|
||||
assert isinstance(iat, int)
|
||||
exp = json["exp"]
|
||||
assert isinstance(exp, int)
|
||||
email = json["email"]
|
||||
assert isinstance(email, str)
|
||||
csrfToken = json["csrf_token"]
|
||||
assert isinstance(csrfToken, str)
|
||||
|
||||
return JwtToken(sub, iat, exp, email, csrfToken)
|
||||
|
||||
|
||||
class TokenState:
|
||||
state: tuple[Tokens, JwtToken] | None
|
||||
headers: dict[str, str]
|
||||
|
||||
def __init__(self, state: tuple[Tokens, JwtToken] | None, headers: dict[str, str]) -> None:
|
||||
self.state = state
|
||||
self.headers = headers
|
||||
|
||||
@staticmethod
|
||||
def build(tokens: Tokens | None) -> "TokenState":
|
||||
decoded = (
|
||||
jwt.decode(tokens.auth, algorithms=["EdDSA"], options={"verify_signature": False})
|
||||
if tokens != None
|
||||
else None
|
||||
)
|
||||
|
||||
if decoded == None or tokens == None:
|
||||
return TokenState(None, TokenState.buildHeaders(tokens))
|
||||
|
||||
return TokenState(
|
||||
(tokens, JwtToken.fromJson(decoded)),
|
||||
TokenState.buildHeaders(tokens),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def buildHeaders(tokens: Tokens | None) -> dict[str, str]:
|
||||
base = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
if tokens != None:
|
||||
base["Authorization"] = f"Bearer {tokens.auth}"
|
||||
|
||||
refresh = tokens.refresh
|
||||
if refresh != None:
|
||||
base["Refresh-Token"] = refresh
|
||||
|
||||
csrf = tokens.csrf
|
||||
if csrf != None:
|
||||
base["CSRF-Token"] = csrf
|
||||
|
||||
return base
|
||||
|
||||
|
||||
class ThinClient:
|
||||
http_client: httpx.Client
|
||||
site: str
|
||||
|
||||
def __init__(self, site: str, http_client: httpx.Client | None = None) -> None:
|
||||
self.site = site
|
||||
self.http_client = http_client or httpx.Client()
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
path: str,
|
||||
tokenState: TokenState,
|
||||
method: str | None = "GET",
|
||||
data: dict[str, Any] | None = None,
|
||||
queryParams: dict[str, str] | None = None,
|
||||
) -> httpx.Response:
|
||||
assert not path.startswith("/")
|
||||
|
||||
logger.debug(f"headers: {data} {tokenState.headers}")
|
||||
|
||||
return self.http_client.request(
|
||||
method=method or "GET",
|
||||
url=f"{self.site}/{path}",
|
||||
json=data,
|
||||
headers=tokenState.headers,
|
||||
params=queryParams,
|
||||
)
|
||||
|
||||
|
||||
class Client:
|
||||
_authApi: str = "api/auth/v1"
|
||||
|
||||
_client: ThinClient
|
||||
_site: str
|
||||
_tokenState: TokenState
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
site: str,
|
||||
tokens: Tokens | None,
|
||||
http_client: httpx.Client | None = None,
|
||||
) -> None:
|
||||
self._client = ThinClient(site, http_client)
|
||||
self._site = site
|
||||
self._tokenState = TokenState.build(tokens)
|
||||
|
||||
def tokens(self) -> Tokens | None:
|
||||
state = self._tokenState.state
|
||||
return state[0] if state else None
|
||||
|
||||
def user(self) -> User | None:
|
||||
tokens = self.tokens()
|
||||
if tokens != None:
|
||||
return User.fromJson(
|
||||
jwt.decode(tokens.auth, algorithms=["EdDSA"], options={"verify_signature": False})
|
||||
)
|
||||
|
||||
def site(self) -> str:
|
||||
return self._site
|
||||
|
||||
def login(self, email: str, password: str) -> Tokens:
|
||||
response = self.fetch(
|
||||
f"{self._authApi}/login",
|
||||
method="POST",
|
||||
data={
|
||||
"email": email,
|
||||
"password": password,
|
||||
},
|
||||
)
|
||||
|
||||
json = response.json()
|
||||
tokens = Tokens(
|
||||
json["auth_token"],
|
||||
json["refresh_token"],
|
||||
json["csrf_token"],
|
||||
)
|
||||
|
||||
self._updateTokens(tokens)
|
||||
return tokens
|
||||
|
||||
def logout(self) -> None:
|
||||
state = self._tokenState.state
|
||||
refreshToken = state[0].refresh if state else None
|
||||
try:
|
||||
if refreshToken != None:
|
||||
self.fetch(
|
||||
f"{self._authApi}/logout",
|
||||
method="POST",
|
||||
data={
|
||||
"refresh_token": refreshToken,
|
||||
},
|
||||
)
|
||||
else:
|
||||
self.fetch(f"{self._authApi}/logout")
|
||||
except:
|
||||
pass
|
||||
|
||||
self._updateTokens(None)
|
||||
|
||||
def records(self, name: str) -> "RecordApi":
|
||||
return RecordApi(name, self)
|
||||
|
||||
def _updateTokens(self, tokens: Tokens | None):
|
||||
state = TokenState.build(tokens)
|
||||
|
||||
self._tokenState = state
|
||||
|
||||
state = state.state
|
||||
if state != None:
|
||||
claims = state[1]
|
||||
now = int(time())
|
||||
if claims.exp < now:
|
||||
logger.warn("Token expired")
|
||||
|
||||
return state
|
||||
|
||||
@staticmethod
|
||||
def _shouldRefresh(tokenState: TokenState) -> str | None:
|
||||
state = tokenState.state
|
||||
now = int(time())
|
||||
if state != None and state[1].exp - 60 < now:
|
||||
return state[0].refresh
|
||||
return None
|
||||
|
||||
def _refreshTokensImpl(self, refreshToken: str) -> TokenState:
|
||||
response = self._client.fetch(
|
||||
f"{self._authApi}/refresh",
|
||||
self._tokenState,
|
||||
method="POST",
|
||||
data={
|
||||
"refresh_token": refreshToken,
|
||||
},
|
||||
)
|
||||
|
||||
json = response.json()
|
||||
return TokenState.build(
|
||||
Tokens(
|
||||
json["auth_token"],
|
||||
refreshToken,
|
||||
json["csrf_token"],
|
||||
)
|
||||
)
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
path: str,
|
||||
method: str | None = "GET",
|
||||
data: dict[str, Any] | None = None,
|
||||
queryParams: dict[str, str] | None = None,
|
||||
) -> httpx.Response:
|
||||
tokenState = self._tokenState
|
||||
refreshToken = Client._shouldRefresh(tokenState)
|
||||
if refreshToken != None:
|
||||
tokenState = self._tokenState = self._refreshTokensImpl(refreshToken)
|
||||
|
||||
response = self._client.fetch(path, tokenState, method=method, data=data, queryParams=queryParams)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class RecordApi:
|
||||
_recordApi: str = "api/records/v1"
|
||||
|
||||
_name: str
|
||||
_client: Client
|
||||
|
||||
def __init__(self, name: str, client: Client) -> None:
|
||||
self._name = name
|
||||
self._client = client
|
||||
|
||||
def list(
|
||||
self,
|
||||
order: list[str] | None = None,
|
||||
filters: list[str] | None = None,
|
||||
cursor: str | None = None,
|
||||
limit: int | None = None,
|
||||
) -> list[dict[str, object]]:
|
||||
params: dict[str, str] = {}
|
||||
|
||||
if cursor != None:
|
||||
params["cursor"] = cursor
|
||||
|
||||
if limit != None:
|
||||
params["limit"] = str(limit)
|
||||
|
||||
if order != None:
|
||||
params["order"] = ",".join(order)
|
||||
|
||||
if filters != None:
|
||||
for filter in filters:
|
||||
(nameOp, value) = filter.split("=", 1)
|
||||
if value == None:
|
||||
raise Exception(f"Filter '{filter}' does not match: 'name[op]=value'")
|
||||
|
||||
params[nameOp] = value
|
||||
|
||||
response = self._client.fetch(f"{self._recordApi}/{self._name}", queryParams=params)
|
||||
return response.json()
|
||||
|
||||
def read(self, recordId: RecordId | str | int) -> dict[str, object]:
|
||||
response = self._client.fetch(f"{self._recordApi}/{self._name}/{repr(recordId)}")
|
||||
return response.json()
|
||||
|
||||
def create(self, record: dict[str, object]) -> RecordId:
|
||||
response = self._client.fetch(
|
||||
f"{RecordApi._recordApi}/{self._name}",
|
||||
method="POST",
|
||||
data=record,
|
||||
)
|
||||
if response.status_code > 200:
|
||||
raise Exception(f"{response}")
|
||||
|
||||
return RecordId.fromJson(response.json())
|
||||
|
||||
def update(self, recordId: RecordId | str | int, record: dict[str, object]) -> None:
|
||||
response = self._client.fetch(
|
||||
f"{RecordApi._recordApi}/{self._name}/{repr(recordId)}",
|
||||
method="PATCH",
|
||||
data=record,
|
||||
)
|
||||
if response.status_code > 200:
|
||||
raise Exception(f"{response}")
|
||||
|
||||
def delete(self, recordId: RecordId | str | int) -> None:
|
||||
response = self._client.fetch(
|
||||
f"{RecordApi._recordApi}/{self._name}/{repr(recordId)}",
|
||||
method="DELETE",
|
||||
)
|
||||
if response.status_code > 200:
|
||||
raise Exception(f"{response}")
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1,4 +1,4 @@
|
||||
# JS/TS client for TrailBase
|
||||
# JS/TS Client for TrailBase
|
||||
|
||||
TrailBase is a [blazingly](https://trailbase.io/reference/benchmarks/) fast,
|
||||
single-file, open-source application server with type-safe APIs, built-in
|
||||
|
||||
247
docs/src/assets/python_logo.svg
Normal file
247
docs/src/assets/python_logo.svg
Normal file
@@ -0,0 +1,247 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
version="1.0"
|
||||
id="svg2"
|
||||
sodipodi:version="0.32"
|
||||
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||
sodipodi:docname="python-logo-only.svg"
|
||||
width="83.371017pt"
|
||||
height="84.291748pt"
|
||||
inkscape:export-filename="python-logo-only.png"
|
||||
inkscape:export-xdpi="232.44"
|
||||
inkscape:export-ydpi="232.44"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<metadata
|
||||
id="metadata371">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<sodipodi:namedview
|
||||
inkscape:window-height="1131"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
guidetolerance="10.0"
|
||||
gridtolerance="10.0"
|
||||
objecttolerance="10.0"
|
||||
borderopacity="1.0"
|
||||
bordercolor="#666666"
|
||||
pagecolor="#ffffff"
|
||||
id="base"
|
||||
inkscape:zoom="2.1461642"
|
||||
inkscape:cx="92.024646"
|
||||
inkscape:cy="47.9926"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:current-layer="svg2"
|
||||
width="210mm"
|
||||
height="40mm"
|
||||
units="mm"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="pt"
|
||||
showgrid="false"
|
||||
inkscape:window-maximized="1" />
|
||||
<defs
|
||||
id="defs4">
|
||||
<linearGradient
|
||||
id="linearGradient2795">
|
||||
<stop
|
||||
style="stop-color:#b8b8b8;stop-opacity:0.49803922;"
|
||||
offset="0"
|
||||
id="stop2797" />
|
||||
<stop
|
||||
style="stop-color:#7f7f7f;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop2799" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="linearGradient2787">
|
||||
<stop
|
||||
style="stop-color:#7f7f7f;stop-opacity:0.5;"
|
||||
offset="0"
|
||||
id="stop2789" />
|
||||
<stop
|
||||
style="stop-color:#7f7f7f;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop2791" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="linearGradient3676">
|
||||
<stop
|
||||
style="stop-color:#b2b2b2;stop-opacity:0.5;"
|
||||
offset="0"
|
||||
id="stop3678" />
|
||||
<stop
|
||||
style="stop-color:#b3b3b3;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop3680" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="linearGradient3236">
|
||||
<stop
|
||||
style="stop-color:#f4f4f4;stop-opacity:1"
|
||||
offset="0"
|
||||
id="stop3244" />
|
||||
<stop
|
||||
style="stop-color:white;stop-opacity:1"
|
||||
offset="1"
|
||||
id="stop3240" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="linearGradient4671">
|
||||
<stop
|
||||
style="stop-color:#ffd43b;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop4673" />
|
||||
<stop
|
||||
style="stop-color:#ffe873;stop-opacity:1"
|
||||
offset="1"
|
||||
id="stop4675" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="linearGradient4689">
|
||||
<stop
|
||||
style="stop-color:#5a9fd4;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop4691" />
|
||||
<stop
|
||||
style="stop-color:#306998;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop4693" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
x1="224.23996"
|
||||
y1="144.75717"
|
||||
x2="-65.308502"
|
||||
y2="144.75717"
|
||||
id="linearGradient2987"
|
||||
xlink:href="#linearGradient4671"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(100.2702,99.61116)" />
|
||||
<linearGradient
|
||||
x1="172.94208"
|
||||
y1="77.475983"
|
||||
x2="26.670298"
|
||||
y2="76.313133"
|
||||
id="linearGradient2990"
|
||||
xlink:href="#linearGradient4689"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(100.2702,99.61116)" />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient4689"
|
||||
id="linearGradient2587"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(100.2702,99.61116)"
|
||||
x1="172.94208"
|
||||
y1="77.475983"
|
||||
x2="26.670298"
|
||||
y2="76.313133" />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient4671"
|
||||
id="linearGradient2589"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(100.2702,99.61116)"
|
||||
x1="224.23996"
|
||||
y1="144.75717"
|
||||
x2="-65.308502"
|
||||
y2="144.75717" />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient4689"
|
||||
id="linearGradient2248"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(100.2702,99.61116)"
|
||||
x1="172.94208"
|
||||
y1="77.475983"
|
||||
x2="26.670298"
|
||||
y2="76.313133" />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient4671"
|
||||
id="linearGradient2250"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(100.2702,99.61116)"
|
||||
x1="224.23996"
|
||||
y1="144.75717"
|
||||
x2="-65.308502"
|
||||
y2="144.75717" />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient4671"
|
||||
id="linearGradient2255"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.562541,0,0,0.567972,-11.5974,-7.60954)"
|
||||
x1="224.23996"
|
||||
y1="144.75717"
|
||||
x2="-65.308502"
|
||||
y2="144.75717" />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient4689"
|
||||
id="linearGradient2258"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.562541,0,0,0.567972,-11.5974,-7.60954)"
|
||||
x1="172.94208"
|
||||
y1="76.176224"
|
||||
x2="26.670298"
|
||||
y2="76.313133" />
|
||||
<radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient2795"
|
||||
id="radialGradient2801"
|
||||
cx="61.518883"
|
||||
cy="132.28575"
|
||||
fx="61.518883"
|
||||
fy="132.28575"
|
||||
r="29.036913"
|
||||
gradientTransform="matrix(1,0,0,0.177966,0,108.7434)"
|
||||
gradientUnits="userSpaceOnUse" />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient4671"
|
||||
id="linearGradient1475"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.562541,0,0,0.567972,-14.99112,-11.702371)"
|
||||
x1="150.96111"
|
||||
y1="192.35176"
|
||||
x2="112.03144"
|
||||
y2="137.27299" />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient4689"
|
||||
id="linearGradient1478"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.562541,0,0,0.567972,-14.99112,-11.702371)"
|
||||
x1="26.648937"
|
||||
y1="20.603781"
|
||||
x2="135.66525"
|
||||
y2="114.39767" />
|
||||
</defs>
|
||||
<path
|
||||
style="fill:url(#linearGradient1478);fill-opacity:1"
|
||||
d="M 54.918785,9.1927424e-4 C 50.335132,0.02221727 45.957846,0.41313697 42.106285,1.0946693 30.760069,3.0991731 28.700036,7.2947714 28.700035,15.032169 v 10.21875 h 26.8125 v 3.40625 h -26.8125 -10.0625 c -7.792459,0 -14.6157588,4.683717 -16.7499998,13.59375 -2.46181998,10.212966 -2.57101508,16.586023 0,27.25 1.9059283,7.937852 6.4575432,13.593748 14.2499998,13.59375 h 9.21875 v -12.25 c 0,-8.849902 7.657144,-16.656248 16.75,-16.65625 h 26.78125 c 7.454951,0 13.406253,-6.138164 13.40625,-13.625 v -25.53125 c 0,-7.2663386 -6.12998,-12.7247771 -13.40625,-13.9374997 C 64.281548,0.32794397 59.502438,-0.02037903 54.918785,9.1927424e-4 Z m -14.5,8.21875012576 c 2.769547,0 5.03125,2.2986456 5.03125,5.1249996 -2e-6,2.816336 -2.261703,5.09375 -5.03125,5.09375 -2.779476,-1e-6 -5.03125,-2.277415 -5.03125,-5.09375 -10e-7,-2.826353 2.251774,-5.1249996 5.03125,-5.1249996 z"
|
||||
id="path1948" />
|
||||
<path
|
||||
style="fill:url(#linearGradient1475);fill-opacity:1"
|
||||
d="m 85.637535,28.657169 v 11.90625 c 0,9.230755 -7.825895,16.999999 -16.75,17 h -26.78125 c -7.335833,0 -13.406249,6.278483 -13.40625,13.625 v 25.531247 c 0,7.266344 6.318588,11.540324 13.40625,13.625004 8.487331,2.49561 16.626237,2.94663 26.78125,0 6.750155,-1.95439 13.406253,-5.88761 13.40625,-13.625004 V 86.500919 h -26.78125 v -3.40625 h 26.78125 13.406254 c 7.792461,0 10.696251,-5.435408 13.406241,-13.59375 2.79933,-8.398886 2.68022,-16.475776 0,-27.25 -1.92578,-7.757441 -5.60387,-13.59375 -13.406241,-13.59375 z m -15.0625,64.65625 c 2.779478,3e-6 5.03125,2.277417 5.03125,5.093747 -2e-6,2.826354 -2.251775,5.125004 -5.03125,5.125004 -2.76955,0 -5.03125,-2.29865 -5.03125,-5.125004 2e-6,-2.81633 2.261697,-5.093747 5.03125,-5.093747 z"
|
||||
id="path1950" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.7 KiB |
@@ -27,8 +27,10 @@ import SplitCard from "@/components/SplitCard.astro";
|
||||
import Roadmap from "./_roadmap.md";
|
||||
|
||||
import screenshot from "@/assets/screenshot.webp";
|
||||
|
||||
import dotnetLogo from "@/assets/dotnet_logo.svg";
|
||||
import flutterLogo from "@/assets/flutter_logo.svg";
|
||||
import pythonLogo from "@/assets/python_logo.svg";
|
||||
import tsLogo from "@/assets/ts_logo.svg";
|
||||
|
||||
import { Duration100kInsertsChart } from "./reference/_benchmarks/benchmarks.tsx";
|
||||
@@ -126,7 +128,7 @@ import { Duration100kInsertsChart } from "./reference/_benchmarks/benchmarks.tsx
|
||||
bindings for virtually any language.
|
||||
|
||||
Clients as well as code-generation examples for TypeScript,
|
||||
Dart/Flutter, and C#/.NET are provided out of the box.
|
||||
Dart/Flutter, Python, and C#/.NET are provided out of the box.
|
||||
|
||||
<div class="m-0 flex justify-center items-start gap-8">
|
||||
<a href="https://www.npmjs.com/package/trailbase">
|
||||
@@ -137,6 +139,10 @@ import { Duration100kInsertsChart } from "./reference/_benchmarks/benchmarks.tsx
|
||||
<Image margin={0} class="p-0 m-0" width={42} height={52} src={flutterLogo} alt="Flutter" />
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/trailbaseio/trailbase/tree/main/client/trailbase-py">
|
||||
<Image margin={0} class="p-0 m-0" width={52} height={52} src={pythonLogo} alt="Dotnet" />
|
||||
</a>
|
||||
|
||||
<a href="https://www.nuget.org/packages/TrailBase/">
|
||||
<Image margin={0} class="p-0 m-0" width={52} height={52} src={dotnetLogo} alt="Dotnet" />
|
||||
</a>
|
||||
|
||||
Reference in New Issue
Block a user