From 67bb09f8543e9250f0922a589f2b4f0631e4ee0a Mon Sep 17 00:00:00 2001
From: Daniel Gerlag
Date: Thu, 30 Jul 2026 16:19:45 -0700
Subject: [PATCH 01/19] Add Building Comfort tutorial (Python + Streamlit)
Port the upstream Drasi Server Building Comfort tutorial to drasi-lib. Keeps
PostgreSQL CDC, six continuous queries with synthetic joins and aggregation,
but replaces the dashboard reaction with a Python reaction that drives a
Streamlit UI. The UI also drives the demo (simulation toggle, reset, and
per-room set), writing SQL UPDATEs to Postgres that Drasi observes via CDC.
- tutorials/building-comfort/: runnable app (app.py + demo/ engine, queries,
config), database assets, env/setup/start/cleanup scripts, requirements.
- Authored once in _index.md (mounted into the website/ Hugo docs) with the
GitHub README generated by scripts/render-tutorials.py (CI --check gate).
- Dev container config, tutorials index, and repo README pointer.
Verified end-to-end against the real engine and Postgres: bootstrap, break /
reset / set a room via CDC, and simulation; screenshot captured from the app.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
.../building-comfort/devcontainer.json | 60 +++
.devcontainer/building-comfort/post-create.sh | 24 ++
.github/workflows/ci.yml | 3 +
.github/workflows/website.yml | 2 +
README.md | 8 +
scripts/render-tutorials.py | 268 +++++++++++++
tutorials/README.md | 23 ++
tutorials/building-comfort/.env.example | 23 ++
tutorials/building-comfort/.gitignore | 11 +
tutorials/building-comfort/README.md | 343 ++++++++++++++++
tutorials/building-comfort/_index.md | 376 ++++++++++++++++++
tutorials/building-comfort/app.py | 226 +++++++++++
.../database/docker-compose.yml | 44 ++
tutorials/building-comfort/database/init.sql | 146 +++++++
tutorials/building-comfort/demo/__init__.py | 19 +
tutorials/building-comfort/demo/config.py | 88 ++++
tutorials/building-comfort/demo/engine.py | 244 ++++++++++++
tutorials/building-comfort/demo/queries.py | 203 ++++++++++
.../images/streamlit-building-view.png | Bin 0 -> 158171 bytes
tutorials/building-comfort/requirements.txt | 11 +
.../building-comfort/scripts/cleanup.ps1 | 48 +++
tutorials/building-comfort/scripts/cleanup.sh | 54 +++
.../scripts/setup-database.ps1 | 105 +++++
.../scripts/setup-database.sh | 120 ++++++
.../building-comfort/scripts/start-demo.ps1 | 42 ++
.../building-comfort/scripts/start-demo.sh | 36 ++
website/config.toml | 15 +
website/content/docs/tutorials/_index.md | 27 ++
28 files changed, 2569 insertions(+)
create mode 100644 .devcontainer/building-comfort/devcontainer.json
create mode 100755 .devcontainer/building-comfort/post-create.sh
create mode 100644 scripts/render-tutorials.py
create mode 100644 tutorials/README.md
create mode 100644 tutorials/building-comfort/.env.example
create mode 100644 tutorials/building-comfort/.gitignore
create mode 100644 tutorials/building-comfort/README.md
create mode 100644 tutorials/building-comfort/_index.md
create mode 100644 tutorials/building-comfort/app.py
create mode 100644 tutorials/building-comfort/database/docker-compose.yml
create mode 100644 tutorials/building-comfort/database/init.sql
create mode 100644 tutorials/building-comfort/demo/__init__.py
create mode 100644 tutorials/building-comfort/demo/config.py
create mode 100644 tutorials/building-comfort/demo/engine.py
create mode 100644 tutorials/building-comfort/demo/queries.py
create mode 100644 tutorials/building-comfort/images/streamlit-building-view.png
create mode 100644 tutorials/building-comfort/requirements.txt
create mode 100644 tutorials/building-comfort/scripts/cleanup.ps1
create mode 100755 tutorials/building-comfort/scripts/cleanup.sh
create mode 100644 tutorials/building-comfort/scripts/setup-database.ps1
create mode 100755 tutorials/building-comfort/scripts/setup-database.sh
create mode 100644 tutorials/building-comfort/scripts/start-demo.ps1
create mode 100755 tutorials/building-comfort/scripts/start-demo.sh
create mode 100644 website/content/docs/tutorials/_index.md
diff --git a/.devcontainer/building-comfort/devcontainer.json b/.devcontainer/building-comfort/devcontainer.json
new file mode 100644
index 0000000..349dab7
--- /dev/null
+++ b/.devcontainer/building-comfort/devcontainer.json
@@ -0,0 +1,60 @@
+{
+ "name": "Building Comfort Tutorial (Python)",
+ "image": "mcr.microsoft.com/devcontainers/base:bookworm@sha256:9e35ebf156350ba25b117da6869907034b6e2437b1891e3a43395af0b7d3eda4",
+ "features": {
+ "ghcr.io/devcontainers/features/docker-in-docker:2": {
+ "version": "29",
+ "moby": false
+ },
+ "ghcr.io/devcontainers/features/python:1": {
+ "version": "3.12",
+ "installTools": true
+ },
+ "ghcr.io/devcontainers/features/git:1": {},
+ "ghcr.io/devcontainers/features/common-utils:2": {
+ "installZsh": true,
+ "configureZshAsDefaultShell": true
+ }
+ },
+ "postCreateCommand": "bash ../../.devcontainer/building-comfort/post-create.sh",
+ "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}/tutorials/building-comfort",
+ "runArgs": [
+ "--privileged",
+ "--init",
+ "--ipc=host"
+ ],
+ "forwardPorts": [
+ 8501,
+ 5732
+ ],
+ "portsAttributes": {
+ "8501": {
+ "label": "Streamlit UI",
+ "onAutoForward": "notify"
+ },
+ "5732": {
+ "label": "PostgreSQL",
+ "onAutoForward": "silent"
+ }
+ },
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "ms-python.python"
+ ],
+ "settings": {
+ "editor.formatOnSave": true
+ }
+ }
+ },
+ "remoteUser": "vscode",
+ "remoteEnv": {
+ "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}"
+ },
+ "containerEnv": {
+ "POSTGRES_HOST": "localhost",
+ "POSTGRES_PORT": "5732",
+ "POSTGRES_HOST_PORT": "5732",
+ "STREAMLIT_PORT": "8501"
+ }
+}
diff --git a/.devcontainer/building-comfort/post-create.sh b/.devcontainer/building-comfort/post-create.sh
new file mode 100755
index 0000000..fd64fb1
--- /dev/null
+++ b/.devcontainer/building-comfort/post-create.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+# Post-create script for the Building Comfort tutorial (Python).
+
+set -e
+
+echo "๐ง Initializing the Building Comfort (Python) tutorial environment..."
+
+# Resolve the tutorial directory from this script's location so the script works
+# regardless of the current working directory.
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+TUTORIAL_DIR="$REPO_ROOT/tutorials/building-comfort"
+
+# Install the tutorial's Python dependencies (drasi-lib, streamlit, psycopg).
+# The devcontainer's Python feature provides a pip that is not externally
+# managed, so a plain install works and puts `streamlit` on PATH.
+echo "๐ Installing Python dependencies..."
+cd "$TUTORIAL_DIR"
+python -m pip install --upgrade pip
+python -m pip install -r requirements.txt
+
+echo ""
+echo "โ
Building Comfort (Python) tutorial environment is ready!"
+echo " Next: run 'bash scripts/start-demo.sh' (you are already in tutorials/building-comfort)"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 11f753f..537437e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -71,6 +71,9 @@ jobs:
- name: Python formatting
run: .venv/bin/ruff format --check .
+ - name: Tutorial READMEs are current
+ run: python3 scripts/render-tutorials.py --check
+
# This went unrun for long enough to accumulate 1653 errors from a single
# misconfiguration, which is exactly the kind of rot a gate is meant to
# prevent. pyright resolves `drasi` from the source tree, so the
diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml
index 9774484..cb4bd6c 100644
--- a/.github/workflows/website.yml
+++ b/.github/workflows/website.yml
@@ -12,11 +12,13 @@ on:
branches: [main]
paths:
- "website/**"
+ - "tutorials/**"
- ".github/workflows/website.yml"
pull_request:
branches: [main]
paths:
- "website/**"
+ - "tutorials/**"
- ".github/workflows/website.yml"
# Allow the deploy job to publish to GitHub Pages via OIDC.
diff --git a/README.md b/README.md
index d6d5422..c4f8354 100644
--- a/README.md
+++ b/README.md
@@ -193,6 +193,14 @@ make venv && make develop
.venv/bin/python examples/python_source.py
```
+## Tutorials
+
+Longer, hands-on walkthroughs live in [`tutorials/`](https://github.com/drasi-project/drasi-python/tree/main/tutorials).
+The [Building Comfort](https://github.com/drasi-project/drasi-python/tree/main/tutorials/building-comfort)
+tutorial builds a smart-building monitor from PostgreSQL CDC, six continuous
+queries with synthetic joins, and a Python reaction that drives a live Streamlit
+UI. They also render on the [documentation site](https://drasi-project.github.io/drasi-python/docs/tutorials/).
+
## Development
```bash
diff --git a/scripts/render-tutorials.py b/scripts/render-tutorials.py
new file mode 100644
index 0000000..838ce77
--- /dev/null
+++ b/scripts/render-tutorials.py
@@ -0,0 +1,268 @@
+#!/usr/bin/env python3
+# Copyright 2026 The Drasi Authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Render Docsy/Hugo tutorial sources into plain Markdown READMEs.
+
+Each tutorial is authored once in an ``_index.md`` that may use Docsy/Hugo
+shortcodes (``{{< tabpane >}}``, ``{{% alert %}}``, ...). The doc site consumes
+``_index.md`` directly (mounted into the Hugo content tree) so the tab widgets
+and styled alerts render. GitHub and plain Markdown viewers cannot process
+shortcodes, so this script generates a sibling ``README.md`` with the shortcodes
+converted to equivalent plain Markdown.
+
+Source of truth: tutorials//_index.md (shortcodes, used by doc site)
+Generated output: tutorials//README.md (plain Markdown, shown on GitHub)
+
+Usage:
+ python3 scripts/render-tutorials.py # write README.md files
+ python3 scripts/render-tutorials.py --check # fail if any are stale
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+TUTORIALS_DIR = REPO_ROOT / "tutorials"
+
+GENERATED_BANNER = (
+ "\n\n"
+)
+
+_ATTR_RE = re.compile(r'(\w+)\s*=\s*"([^"]*)"')
+
+_FRONT_MATTER_RE = re.compile(r"\A---\n.*?\n---\n", re.DOTALL)
+
+_TABPANE_RE = re.compile(
+ r"\{\{<\s*tabpane[^}]*>\}\}(?P.*?)\{\{<\s*/\s*tabpane\s*>\}\}",
+ re.DOTALL,
+)
+_CODE_TAB_RE = re.compile(
+ r"\{\{<\s*tab\s+(?P[^}]*?)>\}\}(?P.*?)\{\{<\s*/\s*tab\s*>\}\}",
+ re.DOTALL,
+)
+_MD_TAB_RE = re.compile(
+ r"\{\{%\s*tab\s+(?P[^}]*?)%\}\}(?P.*?)\{\{%\s*/\s*tab\s*%\}\}",
+ re.DOTALL,
+)
+_ALERT_RE = re.compile(
+ r"\{\{%\s*alert\s+(?P[^}]*?)%\}\}(?P.*?)\{\{%\s*/\s*alert\s*%\}\}",
+ re.DOTALL,
+)
+_CARD_GRID_RE = re.compile(
+ r'^\n(?P.*?)\n
[ \t]*$',
+ re.DOTALL | re.MULTILINE,
+)
+_CARD_RE = re.compile(
+ r'[^"]+)">.*?'
+ r'unified-card-title">(?P.*?).*?'
+ r'unified-card-summary">(?P.*?)
.*?',
+ re.DOTALL,
+)
+_FLOW_DIAGRAM_RE = re.compile(
+ r'^\n(?P.*?)\n
[ \t]*$',
+ re.DOTALL | re.MULTILINE,
+)
+_FLOW_STEP_RE = re.compile(
+ r'flow-step__label">(?P