From 664bd0c36c37106dce63bf85bffa70030c48fffa Mon Sep 17 00:00:00 2001 From: Felix Ingram Date: Tue, 26 Nov 2024 20:48:10 +0000 Subject: [PATCH] SDK for python Python constants are created using the QuickTemplate build pipeline. The SSE generator is a class with `classmethod`s which perform the various SDK functions. The use of classmethods will allow users to extend the class if needed. (Thanks to Tom D. for the suggestion for this, plus the use of `__slots__`.) Also provides helpers and examples for the following frameworks: * Sanic * FastAPI * Django * Quart pyproject updates as suggested by dalito --- .gitignore | 3 +- build/consts_python.qtpl | 60 ++++++ build/run.go | 5 +- examples/python/django/datastar/__init__.py | 0 examples/python/django/datastar/asgi.py | 16 ++ examples/python/django/datastar/settings.py | 123 +++++++++++++ examples/python/django/datastar/urls.py | 26 +++ examples/python/django/datastar/wsgi.py | 16 ++ examples/python/django/db.sqlite3 | 0 examples/python/django/ds/__init__.py | 0 examples/python/django/ds/admin.py | 1 + examples/python/django/ds/apps.py | 6 + .../python/django/ds/migrations/__init__.py | 0 examples/python/django/ds/models.py | 1 + examples/python/django/ds/tests.py | 1 + examples/python/django/ds/views.py | 59 ++++++ examples/python/django/manage.py | 23 +++ examples/python/fastapi/app.py | 66 +++++++ examples/python/quart/app.py | 65 +++++++ examples/python/sanic/app.py | 118 ++++++++++++ sdk/README.md | 2 +- sdk/python/README.md | 40 ++++ sdk/python/pyproject.toml | 82 +++++++++ sdk/python/src/datastar_py/__about__.py | 4 + sdk/python/src/datastar_py/__init__.py | 3 + sdk/python/src/datastar_py/responses.py | 43 +++++ sdk/python/src/datastar_py/sse.py | 174 ++++++++++++++++++ 27 files changed, 933 insertions(+), 4 deletions(-) create mode 100644 build/consts_python.qtpl create mode 100644 examples/python/django/datastar/__init__.py create mode 100644 examples/python/django/datastar/asgi.py create mode 100644 examples/python/django/datastar/settings.py create mode 100644 examples/python/django/datastar/urls.py create mode 100644 examples/python/django/datastar/wsgi.py create mode 100644 examples/python/django/db.sqlite3 create mode 100644 examples/python/django/ds/__init__.py create mode 100644 examples/python/django/ds/admin.py create mode 100644 examples/python/django/ds/apps.py create mode 100644 examples/python/django/ds/migrations/__init__.py create mode 100644 examples/python/django/ds/models.py create mode 100644 examples/python/django/ds/tests.py create mode 100644 examples/python/django/ds/views.py create mode 100755 examples/python/django/manage.py create mode 100644 examples/python/fastapi/app.py create mode 100644 examples/python/quart/app.py create mode 100644 examples/python/sanic/app.py create mode 100644 sdk/python/README.md create mode 100644 sdk/python/pyproject.toml create mode 100644 sdk/python/src/datastar_py/__about__.py create mode 100644 sdk/python/src/datastar_py/__init__.py create mode 100644 sdk/python/src/datastar_py/responses.py create mode 100644 sdk/python/src/datastar_py/sse.py diff --git a/.gitignore b/.gitignore index 62f49d247..735d880a1 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ data node_modules datastar-website *_bin -.DS_Store *.qtpl.go */java/target/ +*.pyc +__pycache__ diff --git a/build/consts_python.qtpl b/build/consts_python.qtpl new file mode 100644 index 000000000..579fb6e55 --- /dev/null +++ b/build/consts_python.qtpl @@ -0,0 +1,60 @@ +{% import "strings" %} +{% import "fmt" %} +{%- func pythonConsts(data *ConstTemplateData) -%} +# {%s data.DoNotEdit %} +from enum import StrEnum + +{%- for _, enum := range data.Enums -%} +#region {%s= enum.Description %} +class {%s enum.Name.Pascal %}(StrEnum): +{%- for _, entry := range enum.Values -%} + # {%s= entry.Description %} + {%s enum.Name.Pascal %}{%s entry.Name.Pascal %} = "{%s entry.Value %}" + +{%- endfor -%} +#endregion {%s enum.Name.Pascal %} + +{%- endfor -%} +#endregion Enums + +DATASTAR_KEY = "{%s data.DatastarKey %}" +VERSION = "{%s data.Version %}" +VERSION_CLIENT_BYTE_SIZE = {%d data.VersionClientByteSize %} +VERSION_CLIENT_BYTE_SIZE_GZIP = {%d data.VersionClientByteSizeGzip %} + +#region Default durations + +{%- for _, d := range data.DefaultDurations -%} +# {%s= d.Description %} +Default{%s d.Name.Pascal %} = {%d durationToMs(d.Duration) %} +{%- endfor -%} + +#endregion Default durations + +#region Default strings + +{%- for _, s := range data.DefaultStrings -%} +# {%s= s.Description %} +Default{%s s.Name.Pascal %} = "{%s s.Value %}" +{%- endfor -%} + +#endregion Default strings + +#region Dataline literals +{%- for _, literal := range data.DatalineLiterals -%} +{%s literal.Pascal %}DatalineLiteral = "{%s literal.Camel %}" +{%- endfor -%} +#endregion Dataline literals + +#region Default booleans + +{%- for _, b := range data.DefaultBools -%} +# {%s= b.Description %} +Default{%s b.Name.Pascal %} = {%s strings.Title(fmt.Sprintf("%v", b.Value)) %} + +{%- endfor -%} +#endregion Default booleans + +#region Enums + +{%- endfunc -%} diff --git a/build/run.go b/build/run.go index 19b971bd3..d2e803b5b 100644 --- a/build/run.go +++ b/build/run.go @@ -44,7 +44,7 @@ func extractVersion() (string, error) { // Write out the version to the version file. versionPath := "library/src/engine/version.ts" versionContents := fmt.Sprintf("export const VERSION = '%s';\n", version) - if err := os.WriteFile(versionPath, []byte(versionContents), 0644); err != nil { + if err := os.WriteFile(versionPath, []byte(versionContents), 0o644); err != nil { return "", fmt.Errorf("error writing version file: %w", err) } @@ -143,12 +143,13 @@ func writeOutConsts(version string) error { "sdk/java/src/main/java/starfederation/datastar/Consts.java": javaConsts, "sdk/java/src/main/java/starfederation/datastar/enums/EventType.java": javaEventType, "sdk/java/src/main/java/starfederation/datastar/enums/FragmentMergeMode.java": javaFragmentMergeMode, + "sdk/python/src/datastar_py/consts.py": pythonConsts, } for path, tmplFn := range templates { log.Printf("Writing %s...", path) contents := strings.TrimSpace(tmplFn(Consts)) - if err := os.WriteFile(path, []byte(contents), 0644); err != nil { + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { return fmt.Errorf("error writing version file: %w", err) } } diff --git a/examples/python/django/datastar/__init__.py b/examples/python/django/datastar/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/python/django/datastar/asgi.py b/examples/python/django/datastar/asgi.py new file mode 100644 index 000000000..73df6db5a --- /dev/null +++ b/examples/python/django/datastar/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for datastar project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "datastar.settings") + +application = get_asgi_application() diff --git a/examples/python/django/datastar/settings.py b/examples/python/django/datastar/settings.py new file mode 100644 index 000000000..446f89604 --- /dev/null +++ b/examples/python/django/datastar/settings.py @@ -0,0 +1,123 @@ +""" +Django settings for datastar project. + +Generated by 'django-admin startproject' using Django 4.2.16. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-_=(hbyv-s)mgn*5d!0wmq#-dd7wq4v6200+^5anv+5lt_8sp*)" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "datastar.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "datastar.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/examples/python/django/datastar/urls.py b/examples/python/django/datastar/urls.py new file mode 100644 index 000000000..a8c44e95f --- /dev/null +++ b/examples/python/django/datastar/urls.py @@ -0,0 +1,26 @@ +""" +URL configuration for datastar project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from django.contrib import admin +from django.urls import path +from ds import views + +urlpatterns = [ + path("admin/", admin.site.urls), + path("", views.home), + path("updates", views.updates), +] diff --git a/examples/python/django/datastar/wsgi.py b/examples/python/django/datastar/wsgi.py new file mode 100644 index 000000000..2f15d8942 --- /dev/null +++ b/examples/python/django/datastar/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for datastar project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "datastar.settings") + +application = get_wsgi_application() diff --git a/examples/python/django/db.sqlite3 b/examples/python/django/db.sqlite3 new file mode 100644 index 000000000..e69de29bb diff --git a/examples/python/django/ds/__init__.py b/examples/python/django/ds/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/python/django/ds/admin.py b/examples/python/django/ds/admin.py new file mode 100644 index 000000000..846f6b406 --- /dev/null +++ b/examples/python/django/ds/admin.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/examples/python/django/ds/apps.py b/examples/python/django/ds/apps.py new file mode 100644 index 000000000..d311dacf6 --- /dev/null +++ b/examples/python/django/ds/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class DsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "ds" diff --git a/examples/python/django/ds/migrations/__init__.py b/examples/python/django/ds/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/python/django/ds/models.py b/examples/python/django/ds/models.py new file mode 100644 index 000000000..6b2021999 --- /dev/null +++ b/examples/python/django/ds/models.py @@ -0,0 +1 @@ +# Create your models here. diff --git a/examples/python/django/ds/tests.py b/examples/python/django/ds/tests.py new file mode 100644 index 000000000..a39b155ac --- /dev/null +++ b/examples/python/django/ds/tests.py @@ -0,0 +1 @@ +# Create your tests here. diff --git a/examples/python/django/ds/views.py b/examples/python/django/ds/views.py new file mode 100644 index 000000000..a0fea9812 --- /dev/null +++ b/examples/python/django/ds/views.py @@ -0,0 +1,59 @@ +import asyncio +from datetime import datetime + +from django.http import HttpResponse + +from datastar_py.responses import DatastarDjangoResponse + +HTML = """\ + + + + DATASTAR on Django + + + + + +
+
+ Current time from fragment: CURRENT_TIME +
+
+ Current time from signal: CURRENT_TIME +
+
+ + +""" + + +async def home(request): + return HttpResponse( + HTML.replace("CURRENT_TIME", f"{datetime.isoformat(datetime.now())}") + ) + + +async def updates(request): + async def time_updates(sse): + while True: + yield sse.merge_fragments( + [f"""{datetime.now().isoformat()}"""] + ) + await asyncio.sleep(1) + yield sse.merge_signals({"currentTime": f"{datetime.now().isoformat()}"}) + await asyncio.sleep(1) + + return DatastarDjangoResponse(time_updates) diff --git a/examples/python/django/manage.py b/examples/python/django/manage.py new file mode 100755 index 000000000..ed1686813 --- /dev/null +++ b/examples/python/django/manage.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" + +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "datastar.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/examples/python/fastapi/app.py b/examples/python/fastapi/app.py new file mode 100644 index 000000000..7efb67b6d --- /dev/null +++ b/examples/python/fastapi/app.py @@ -0,0 +1,66 @@ +import asyncio +from datetime import datetime + +from fastapi import FastAPI +from fastapi.responses import HTMLResponse + +from datastar_py.responses import DatastarFastAPIResponse + +app = FastAPI() + + +HTML = """\ + + + + DATASTAR on FastAPI + + + + + +
+
+ Current time from fragment: CURRENT_TIME +
+
+ Current time from signal: CURRENT_TIME +
+
+ + +""" + + +@app.get("/") +async def read_root(): + return HTMLResponse( + HTML.replace("CURRENT_TIME", f"{datetime.isoformat(datetime.now())}") + ) + + +async def time_updates(sse): + while True: + yield sse.merge_fragments( + [f"""{datetime.now().isoformat()}"""] + ) + await asyncio.sleep(1) + yield sse.merge_signals({"currentTime": f"{datetime.now().isoformat()}"}) + await asyncio.sleep(1) + + +@app.get("/updates") +async def updates(): + return DatastarFastAPIResponse(time_updates) diff --git a/examples/python/quart/app.py b/examples/python/quart/app.py new file mode 100644 index 000000000..4c9a8b7e2 --- /dev/null +++ b/examples/python/quart/app.py @@ -0,0 +1,65 @@ +import asyncio +from datetime import datetime + +from quart import Quart + +from datastar_py.responses import make_datastar_quart_response + +app = Quart(__name__) + +HTML = """\ + + + + DATASTAR on Quart + + + + + +
+
+ Current time from fragment: CURRENT_TIME +
+
+ Current time from signal: CURRENT_TIME +
+
+ + +""" + + +@app.route("/updates") +async def updates(): + async def time_updates(sse): + while True: + yield sse.merge_fragments( + [f"""{datetime.now().isoformat()}"""] + ) + await asyncio.sleep(1) + yield sse.merge_signals({"currentTime": f"{datetime.now().isoformat()}"}) + await asyncio.sleep(1) + + response = await make_datastar_quart_response(time_updates) + return response + + +@app.route("/") +async def hello(): + return HTML.replace("CURRENT_TIME", f"{datetime.isoformat(datetime.now())}") + + +# app.run() diff --git a/examples/python/sanic/app.py b/examples/python/sanic/app.py new file mode 100644 index 000000000..816970385 --- /dev/null +++ b/examples/python/sanic/app.py @@ -0,0 +1,118 @@ +import asyncio +from datetime import datetime + +from sanic import Sanic +from sanic.response import html + +from datastar_py import ServerSentEventGenerator as SSE +from datastar_py.consts import FragmentMergeMode +from datastar_py.responses import make_datastar_sanic_response + +app = Sanic("DataStarApp") + +HTML = """\ + + + + DATASTAR on Sanic + + + + + +
+ + +
+ Current time from fragment: CURRENT_TIME +
+
+ Current time from signal: CURRENT_TIME +
+
+ + +""" + + +@app.get("/") +async def hello_world(request): + return html(HTML.replace("CURRENT_TIME", f"{datetime.isoformat(datetime.now())}")) + + +@app.get("/add_signal") +async def add_signal(request): + response = await make_datastar_sanic_response(request) + + await response.send( + SSE.merge_fragments( + [ + """ +
+ Current time from signal: CURRENT_TIME +
+ """ + ], + selector="#timers", + merge_mode=FragmentMergeMode.FragmentMergeModeAppend, + ) + ) + + await response.eof() + + +@app.get("/add_fragment") +async def add_fragment(request): + response = await make_datastar_sanic_response(request) + + await response.send( + SSE.merge_fragments( + [ + f"""\ +
+ Current time from fragment: {datetime.now().isoformat()} +
+ """ + ], + selector="#timers", + merge_mode=FragmentMergeMode.FragmentMergeModeAppend, + ) + ) + + await response.eof() + + +@app.get("/updates") +async def updates(request): + response = await make_datastar_sanic_response(request) + + while True: + await response.send( + SSE.merge_fragments( + [ + f""" +
+ Current time from fragment: {datetime.now().isoformat()} +
+ """ + ], + selector=".fragment", + ) + ) + await asyncio.sleep(1) + await response.send( + SSE.merge_signals({"currentTime": f"{datetime.now().isoformat()}"}) + ) + await asyncio.sleep(1) diff --git a/sdk/README.md b/sdk/README.md index cc5b71afc..6afcaabe0 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -19,7 +19,7 @@ Provide an SDK in a language agnostic way, to that end - [ ] JS/TS - [x] PHP - [x] .NET - - [ ] Python + - [x] Python - [x] Java - [ ] Haskell? diff --git a/sdk/python/README.md b/sdk/python/README.md new file mode 100644 index 000000000..628e0b689 --- /dev/null +++ b/sdk/python/README.md @@ -0,0 +1,40 @@ +# Datastar-py + +The `datastar_py` package provides backend helpers for the [Datastar](https://data-star.dev) JS library. + +Datastar requires all backend responses to use SSE. This allows the backend to +send any number of responses, from zero to inifinity. + +`Datastar-py` helps with the formatting of these responses, while also +providing helper functions for the different supported responses. + +To use `datastar-py`, import the SSE generator in your app and then use +it in your route handler: + +```python +from datastar_py import ServerSentEventGenerator as SSE + +# ... various app setup. The example below is for the Quart framework + +@app.route("/updates") +async def updates(): + async def time_updates(): + while True: + yield SSE.merge_fragments( + [f"""{datetime.now().isoformat()}"""] + ) + await asyncio.sleep(1) + yield SSE.merge_signals({"currentTime": f"{datetime.now().isoformat()}"}) + await asyncio.sleep(1) + + response = await make_response(time_updates(), SSE_HEADERS) + response.timeout = None + return response +``` + +There are also a number of custom responses/helpers for various frameworks. Current ly the following frameworks are supported: + +* [Sanic](https://sanic.dev/en/) +* [Django](https://www.djangoproject.com/) +* [Quart](https://quart.palletsprojects.com/en/stable/) +* [FastAPI](https://fastapi.tiangolo.com/) diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml new file mode 100644 index 000000000..de1d1d08e --- /dev/null +++ b/sdk/python/pyproject.toml @@ -0,0 +1,82 @@ +[project] +name = "datastar-py" +description = "Helper functions and classes for the Datastar library (https://data-star.dev/)" +readme = "README.md" +authors = [ + { name = "Felix Ingram", email = "f.ingram@gmail.com" } +] +requires-python = ">=3.9" +dependencies = [] +dynamic=["version"] +license = {text = "MIT"} +keywords = ["datastar", "django", "fastapi", "flask", "quart", "sanic", "html"] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Environment :: Web Environment", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] + +[project.urls] +Documentation = "https://github.com/starfederation/datastar/blob/develop/sdk/python/README.md" +GitHub = "https://github.com/starfederation/datastar" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[dependency-groups] +dev = [ + "django>=4.2.17", + "fastapi[standard]>=0.115.4", + "flask[async]>=3.0.3", + "quart>=0.19.9", + "sanic>=24.6.0", + "uvicorn>=0.32.1", +] + +[tool.hatch.version] +path = "src/datastar_py/__about__.py" + +[[tool.bumpversion.files]] +filename = "src/datastar_py/__about__.py" + +[tool.bumpversion] +current_version = "0.4.2" +parse = """(?x) + (?P\\d+)\\. + (?P\\d+)\\. + (?P\\d+) + (?: + \\.post + (?P0|[1-9]\\d*) # post-release versions + )? +""" +serialize = [ + "{major}.{minor}.{patch}.post{postn}", + "{major}.{minor}.{patch}", + +] +search = "{current_version}" +replace = "{new_version}" +regex = false +ignore_missing_version = false +ignore_missing_files = false +tag = false +sign_tags = false +tag_name = "v{new_version}" +tag_message = "Bump version: {current_version} → {new_version}" +allow_dirty = true +commit = false +message = "Bump version: {current_version} → {new_version}" +commit_args = "" +setup_hooks = [] +pre_commit_hooks = [] +post_commit_hooks = [] diff --git a/sdk/python/src/datastar_py/__about__.py b/sdk/python/src/datastar_py/__about__.py new file mode 100644 index 000000000..e5f47e6c0 --- /dev/null +++ b/sdk/python/src/datastar_py/__about__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2024-present Felix Ingram +# +# SPDX-License-Identifier: MIT +__version__ = "0.4.2" diff --git a/sdk/python/src/datastar_py/__init__.py b/sdk/python/src/datastar_py/__init__.py new file mode 100644 index 000000000..efcb9172a --- /dev/null +++ b/sdk/python/src/datastar_py/__init__.py @@ -0,0 +1,3 @@ +from .sse import SSE_HEADERS, ServerSentEventGenerator + +__all__ = ["ServerSentEventGenerator", "SSE_HEADERS"] diff --git a/sdk/python/src/datastar_py/responses.py b/sdk/python/src/datastar_py/responses.py new file mode 100644 index 000000000..80e96c2d9 --- /dev/null +++ b/sdk/python/src/datastar_py/responses.py @@ -0,0 +1,43 @@ +from .sse import SSE_HEADERS, ServerSentEventGenerator + +try: + from django.http import StreamingHttpResponse as DjangoStreamingHttpResponse +except ImportError: + + class DjangoStreamingHttpResponse: ... + + +try: + from fastapi.responses import StreamingResponse as FastAPIStreamingResponse +except ImportError: + + class FastAPIStreamingResponse: ... + + +try: + from quart import make_response +except ImportError: + pass + + +class DatastarDjangoResponse(DjangoStreamingHttpResponse): + def __init__(self, generator, *args, **kwargs): + kwargs["headers"] = SSE_HEADERS + super().__init__(generator(ServerSentEventGenerator), *args, **kwargs) + + +class DatastarFastAPIResponse(FastAPIStreamingResponse): + def __init__(self, generator, *args, **kwargs): + kwargs["headers"] = SSE_HEADERS + super().__init__(generator(ServerSentEventGenerator), *args, **kwargs) + + +async def make_datastar_quart_response(generator): + response = await make_response(generator(ServerSentEventGenerator), SSE_HEADERS) + response.timeout = None + return response + + +async def make_datastar_sanic_response(request): + response = await request.respond(headers=SSE_HEADERS) + return response diff --git a/sdk/python/src/datastar_py/sse.py b/sdk/python/src/datastar_py/sse.py new file mode 100644 index 000000000..0eeb69917 --- /dev/null +++ b/sdk/python/src/datastar_py/sse.py @@ -0,0 +1,174 @@ +import json +from itertools import chain +from typing import Optional + +import datastar_py.consts as consts + +SSE_HEADERS = { + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Content-Type": "text/event-stream", +} + + +class ServerSentEventGenerator: + __slots__ = () + + @classmethod + def _send( + cls, + event_type: consts.EventType, + data_lines: list[str], + event_id: Optional[int] = None, + retry_duration: int = consts.DefaultSseRetryDuration, + ) -> str: + prefix = [] + if event_id: + prefix.append(f"id: {event_id}") + + prefix.append(f"event: {event_type}") + + if retry_duration: + prefix.append(f"retry: {retry_duration}") + + data_lines.append("\n") + + return "\n".join(chain(prefix, data_lines)) + + @classmethod + def merge_fragments( + cls, + fragments: list[str], + selector: Optional[str] = None, + merge_mode: Optional[consts.FragmentMergeMode] = None, + settle_duration: Optional[int] = None, + use_view_transition: bool = consts.DefaultFragmentsUseViewTransitions, + event_id: Optional[int] = None, + retry_duration: int = consts.DefaultSseRetryDuration, + ): + data_lines = [] + if merge_mode: + data_lines.append(f"data: {consts.MergeModeDatalineLiteral} {merge_mode}") + if selector: + data_lines.append(f"data: {consts.SelectorDatalineLiteral} {selector}") + if use_view_transition: + data_lines.append(f"data: {consts.UseViewTransitionDatalineLiteral} true") + else: + data_lines.append(f"data: {consts.UseViewTransitionDatalineLiteral} false") + if settle_duration: + data_lines.append( + f"data: {consts.SettleDurationDatalineLiteral} {settle_duration}" + ) + + data_lines.extend( + f"data: {consts.FragmentsDatalineLiteral} {x}" + for fragment in fragments + for x in fragment.splitlines() + ) + + return ServerSentEventGenerator._send( + consts.EventType.EventTypeMergeFragments, + data_lines, + event_id, + retry_duration, + ) + + @classmethod + def remove_fragments( + cls, + selector: Optional[str] = None, + settle_duration: Optional[int] = None, + use_view_transition: bool = True, + event_id: Optional[int] = None, + retry_duration: int = consts.DefaultSseRetryDuration, + ): + data_lines = [] + if selector: + data_lines.append(f"data: {consts.SelectorDatalineLiteral} {selector}") + if use_view_transition: + data_lines.append(f"data: {consts.UseViewTransitionDatalineLiteral} true") + else: + data_lines.append(f"data: {consts.UseViewTransitionDatalineLiteral} false") + if settle_duration: + data_lines.append( + f"data: {consts.SettleDurationDatalineLiteral} {settle_duration}" + ) + + return ServerSentEventGenerator._send( + consts.EventType.EventTypeRemoveFragments, + data_lines, + event_id, + retry_duration, + ) + + @classmethod + def merge_signals( + cls, + signals: dict, + event_id: Optional[int] = None, + only_if_missing: bool = False, + retry_duration: int = consts.DefaultSseRetryDuration, + ): + data_lines = [] + if only_if_missing: + data_lines.append(f"data: {consts.OnlyIfMissingDatalineLiteral} true") + + data_lines.extend( + f"data: {consts.SignalsDatalineLiteral} {signalLine}" + for signalLine in json.dumps(signals, indent=2).splitlines() + ) + + return ServerSentEventGenerator._send( + consts.EventType.EventTypeMergeSignals, data_lines, event_id, retry_duration + ) + + @classmethod + def remove_signals( + cls, + paths: list[str], + event_id: Optional[int] = None, + retry_duration: int = consts.DefaultSseRetryDuration, + ): + data_lines = [] + + data_lines.extend( + f"data: {consts.PathsDatalineLiteral} {path}" for path in paths + ) + + return ServerSentEventGenerator._send( + consts.EventType.EventTypeRemoveSignals, + data_lines, + event_id, + retry_duration, + ) + + @classmethod + def execute_script( + cls, + script: str, + auto_remove: bool = True, + attributes: Optional[list[str]] = None, + event_id: Optional[int] = None, + retry_duration: int = consts.DefaultSseRetryDuration, + ): + data_lines = [] + data_lines.append(f"data: {consts.AutoRemoveDatalineLiteral} {auto_remove}") + + if attributes: + data_lines.extend( + f"data: {consts.AttributesDatalineLiteral} {attribute}" + for attribute in attributes + if attribute.strip() != consts.DefaultExecuteScriptAttributes + ) + + data_lines.extend( + f"data: {consts.ScriptDatalineLiteral} {script_line}" + for script_line in script.splitlines() + ) + + return ServerSentEventGenerator._send( + consts.EventType.EventTypeExecuteScript, + data_lines, + event_id, + retry_duration, + )