From 07f0500f9fd6b5c49bd66e45bc10820bc14187ae Mon Sep 17 00:00:00 2001 From: Sebastian Jeltsch Date: Tue, 26 Nov 2024 00:29:55 +0100 Subject: [PATCH] Add fist version of a TrailBase Python client including tests and CI setup --- .github/workflows/test.yml | 7 +- .pre-commit-config.yaml | 27 +- Makefile | 6 +- README.md | 1 + .../trailbase-dart/test/trailbase_test.dart | 8 + client/trailbase-dotnet/README.md | 2 +- client/trailbase-py/.gitignore | 2 + client/trailbase-py/README.md | 8 + client/trailbase-py/poetry.toml | 3 + client/trailbase-py/pyproject.toml | 28 ++ client/trailbase-py/tests/__init__.py | 0 client/trailbase-py/tests/test_client.py | 158 +++++++ client/trailbase-py/trailbase/__init__.py | 393 ++++++++++++++++++ client/trailbase-ts/README.md | 2 +- docs/src/assets/python_logo.svg | 247 +++++++++++ docs/src/content/docs/index.mdx | 8 +- 16 files changed, 893 insertions(+), 7 deletions(-) create mode 100644 client/trailbase-py/.gitignore create mode 100644 client/trailbase-py/README.md create mode 100644 client/trailbase-py/poetry.toml create mode 100644 client/trailbase-py/pyproject.toml create mode 100644 client/trailbase-py/tests/__init__.py create mode 100644 client/trailbase-py/tests/test_client.py create mode 100644 client/trailbase-py/trailbase/__init__.py create mode 100644 docs/src/assets/python_logo.svg diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6d468f14..8b4fcdc6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 227d7eb1..59a0fa3a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/Makefile b/Makefile index edabc1bb..6191a663 100644 --- a/Makefile +++ b/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 diff --git a/README.md b/README.md index 91d60e4c..115e442c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/client/trailbase-dart/test/trailbase_test.dart b/client/trailbase-dart/test/trailbase_test.dart index bb49e2e6..eb54fc99 100644 --- a/client/trailbase-dart/test/trailbase_test.dart +++ b/client/trailbase-dart/test/trailbase_test.dart @@ -149,6 +149,14 @@ Future 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); }); }); } diff --git a/client/trailbase-dotnet/README.md b/client/trailbase-dotnet/README.md index a5c761c2..101879e6 100644 --- a/client/trailbase-dotnet/README.md +++ b/client/trailbase-dotnet/README.md @@ -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 diff --git a/client/trailbase-py/.gitignore b/client/trailbase-py/.gitignore new file mode 100644 index 00000000..433fa0de --- /dev/null +++ b/client/trailbase-py/.gitignore @@ -0,0 +1,2 @@ +**/__pycache__/ +poetry.lock diff --git a/client/trailbase-py/README.md b/client/trailbase-py/README.md new file mode 100644 index 00000000..0c8a8192 --- /dev/null +++ b/client/trailbase-py/README.md @@ -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). diff --git a/client/trailbase-py/poetry.toml b/client/trailbase-py/poetry.toml new file mode 100644 index 00000000..62e2dff2 --- /dev/null +++ b/client/trailbase-py/poetry.toml @@ -0,0 +1,3 @@ +[virtualenvs] +in-project = true +create = true diff --git a/client/trailbase-py/pyproject.toml b/client/trailbase-py/pyproject.toml new file mode 100644 index 00000000..01ce8d29 --- /dev/null +++ b/client/trailbase-py/pyproject.toml @@ -0,0 +1,28 @@ +[tool.poetry] +name = "trailbase" +version = "0.1.0" +description = "" +authors = ["TrailBase "] +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" diff --git a/client/trailbase-py/tests/__init__.py b/client/trailbase-py/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/client/trailbase-py/tests/test_client.py b/client/trailbase-py/tests/test_client.py new file mode 100644 index 00000000..b9fc1700 --- /dev/null +++ b/client/trailbase-py/tests/test_client.py @@ -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__) diff --git a/client/trailbase-py/trailbase/__init__.py b/client/trailbase-py/trailbase/__init__.py new file mode 100644 index 00000000..38df1969 --- /dev/null +++ b/client/trailbase-py/trailbase/__init__.py @@ -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__) diff --git a/client/trailbase-ts/README.md b/client/trailbase-ts/README.md index 14a90320..8175978e 100644 --- a/client/trailbase-ts/README.md +++ b/client/trailbase-ts/README.md @@ -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 diff --git a/docs/src/assets/python_logo.svg b/docs/src/assets/python_logo.svg new file mode 100644 index 00000000..ecb5e796 --- /dev/null +++ b/docs/src/assets/python_logo.svg @@ -0,0 +1,247 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 9f3ec4b9..5bf6823e 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -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.