Add fist version of a TrailBase Python client including tests and CI setup

This commit is contained in:
Sebastian Jeltsch
2024-11-26 00:29:55 +01:00
parent 39acdf90c4
commit 07f0500f9f
16 changed files with 893 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
});
});
}

View File

@@ -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
View File

@@ -0,0 +1,2 @@
**/__pycache__/
poetry.lock

View 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).

View File

@@ -0,0 +1,3 @@
[virtualenvs]
in-project = true
create = true

View 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"

View File

View 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__)

View 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__)

View File

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

View 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

View File

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