diff --git a/README.md b/README.md
index 103b8e10a..f17f37dcf 100644
--- a/README.md
+++ b/README.md
@@ -53,6 +53,7 @@ build and run them.
- [Go TUI](go-tui/README.md)
- [Javascript TUI](javascript-tui/README.md)
- [Javascript Web](javascript-web/README.md)
+- [Python TUI](python-tui/README.md)
- [React Native](react-native/README.md)
- [React Native Expo](react-native-expo/README.md)
- [Rust TUI](rust-tui/README.md)
diff --git a/justfile b/justfile
index d53db73e1..0328238c8 100644
--- a/justfile
+++ b/justfile
@@ -33,3 +33,31 @@ tools: cmdline_tools
@echo "Installing tools"
{{cmdline_tools_dir}}/latest/bin/sdkmanager --install 'build-tools;35.0.0'
{{cmdline_tools_dir}}/latest/bin/sdkmanager --install 'platforms;android-34'
+
+# Runs the Python TUI quickstart
+python-tui:
+ #!/usr/bin/env bash
+ set -euo pipefail
+
+ cd python-tui
+ if command -v uv >/dev/null 2>&1; then
+ uv run --with dittolive-ditto main.py
+ else
+ [[ -d .venv ]] || python3 -m venv .venv
+ .venv/bin/python -m pip install --quiet -e .
+ .venv/bin/python main.py
+ fi
+
+# Runs the Python TUI quickstart's credential-free CRUD self-test
+python-tui-smoke:
+ #!/usr/bin/env bash
+ set -euo pipefail
+
+ cd python-tui
+ if command -v uv >/dev/null 2>&1; then
+ uv run --with dittolive-ditto main.py --smoke
+ else
+ [[ -d .venv ]] || python3 -m venv .venv
+ .venv/bin/python -m pip install --quiet -e .
+ .venv/bin/python main.py --smoke
+ fi
diff --git a/python-tui/.env.sample b/python-tui/.env.sample
new file mode 100644
index 000000000..ab67f7cff
--- /dev/null
+++ b/python-tui/.env.sample
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+
+# Copy this file from ".env.sample" to ".env", then fill in these values
+# A Ditto Database ID, development token, and server URL can be obtained from
+# https://portal.ditto.live
+DITTO_DATABASE_ID=""
+DITTO_DEVELOPMENT_TOKEN=""
+DITTO_SERVER_URL=""
diff --git a/python-tui/.gitignore b/python-tui/.gitignore
new file mode 100644
index 000000000..b5209da99
--- /dev/null
+++ b/python-tui/.gitignore
@@ -0,0 +1,7 @@
+.venv/
+__pycache__/
+*.pyc
+.env
+ditto-python-tui-*/
+*.egg-info/
+uv.lock
diff --git a/python-tui/README.md b/python-tui/README.md
new file mode 100644
index 000000000..f543c3ea3
--- /dev/null
+++ b/python-tui/README.md
@@ -0,0 +1,103 @@
+# Ditto Python Quickstart App 🐍
+
+This directory contains Ditto's quickstart app for the Python SDK.
+This app is a console application that manages a todo list that syncs
+between multiple peers.
+
+It shares the `tasks` collection schema with every other quickstart app in
+this repository, so it syncs with the Swift, Kotlin, Go, Rust, and JavaScript
+quickstarts.
+
+## Requirements
+
+- **Python 3.10 or later.** The SDK is tested against 3.10–3.13; 3.14 also
+ works. See the [Python Compatibility page](https://docs.ditto.live/sdk/latest/compatibility/python).
+- A Ditto [Portal][0] account with a Database configured for **Development**
+ authentication.
+
+[0]: https://portal.ditto.live
+
+The Ditto Python SDK ships prebuilt wheels that bundle the native library, so
+there is no separate shared-library download step.
+
+## Getting Started
+
+Find your Database ID, Development Token, and Server URL in the
+[Ditto Portal][0], then create a `.env` file in this directory:
+
+```bash
+cp .env.sample .env
+```
+
+```bash
+DITTO_DATABASE_ID="your-database-id"
+DITTO_DEVELOPMENT_TOKEN="your-development-token"
+DITTO_SERVER_URL="your-server-url"
+```
+
+Alternatively, set them as environment variables, or use the shared `.env` at
+the root of this repository — the app checks this directory first, then the
+repo root.
+
+## Running
+
+Using [uv][1] (recommended — it installs Python and dependencies for you):
+
+```bash
+uv run --with dittolive-ditto main.py
+```
+
+[1]: https://docs.astral.sh/uv/
+
+Or with a virtual environment and pip:
+
+```bash
+python3 -m venv .venv
+source .venv/bin/activate # Windows: .venv\Scripts\activate
+pip install dittolive-ditto
+python main.py
+```
+
+### Commands
+
+Once running, the app accepts these commands:
+
+| Command | Description |
+| --- | --- |
+| `add
` | Create a task |
+| `done ` | Toggle a task's completed state |
+| `edit ` | Rename a task |
+| `del ` | Delete a task |
+| `list` | Refresh the list |
+| `help` | Show help |
+| `quit` | Exit |
+
+Press Enter (or type `list`) to refresh and pick up changes made by other
+peers.
+
+Pass `--verbose` to see the SDK's own logs, which are suppressed by default so
+they don't scribble over the console UI.
+
+## Sync Data Offline
+
+1. Launch the application on multiple devices, or alongside another quickstart
+ app.
+2. Disconnect from your WiFi network while keeping WiFi enabled on the device,
+ to allow for LAN connections.
+3. Add, edit, and delete tasks and experience offline collaboration!
+
+## Self-test
+
+To verify your installation without any credentials or network access:
+
+```bash
+uv run --with dittolive-ditto main.py --smoke
+```
+
+This opens a temporary local peer and runs the full insert / update / rename /
+delete path, asserting that the store observer reports each change.
+
+Note that `--smoke` does **not** start sync. Starting sync requires an
+activated instance — without a license token the SDK raises
+`DittoError `. The local store, subscriptions, and observers all
+work regardless; only replication with other peers needs activation.
diff --git a/python-tui/main.py b/python-tui/main.py
new file mode 100644
index 000000000..e724c7217
--- /dev/null
+++ b/python-tui/main.py
@@ -0,0 +1,456 @@
+#!/usr/bin/env python3
+"""Ditto Python quickstart — a Tasks console app that syncs peer-to-peer.
+
+This mirrors the other quickstart apps (go-tui, rust-tui, javascript-tui): it
+manages a shared "tasks" collection using the canonical cross-SDK schema, so it
+interoperates with every other quickstart app.
+
+ { "_id": str, "title": str, "done": bool, "deleted": bool }
+
+Configuration comes from the environment (or a .env file in this directory):
+
+ DITTO_DATABASE_ID Database ID from the Ditto Portal
+ DITTO_DEVELOPMENT_TOKEN Development token from the Ditto Portal
+ DITTO_SERVER_URL Server URL from the Ditto Portal
+
+Run with --smoke to exercise the full CRUD path against a temporary local
+peer, with no credentials and no network. That mode is what CI runs.
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import os
+import sys
+import tempfile
+import uuid
+from dataclasses import dataclass
+from datetime import timedelta
+from pathlib import Path
+from typing import Any
+
+from ditto import (
+ AuthenticationProvider,
+ Ditto,
+ DittoConfig,
+ DittoConfigConnect,
+ DittoLogger,
+ LogLevel,
+ QueryResult,
+)
+
+# The observer view hides soft-deleted tasks; the subscription syncs everything
+# so that tombstones propagate to other peers.
+TASKS_QUERY = "SELECT * FROM tasks WHERE deleted = false ORDER BY title"
+SUBSCRIPTION_QUERY = "SELECT * FROM tasks"
+
+
+@dataclass
+class Task:
+ id: str
+ title: str
+ done: bool
+
+ @classmethod
+ def from_value(cls, value: dict[str, Any]) -> "Task":
+ return cls(
+ id=str(value["_id"]),
+ title=str(value.get("title", "")),
+ done=bool(value.get("done", False)),
+ )
+
+
+@dataclass
+class Settings:
+ database_id: str
+ persistence_directory: str
+ server_url: str | None = None
+ development_token: str | None = None
+
+ @property
+ def offline(self) -> bool:
+ return self.server_url is None
+
+
+def load_dotenv(path: Path) -> None:
+ """Load KEY=VALUE pairs from a .env file into os.environ.
+
+ Written out rather than pulled from python-dotenv so that the quickstart's
+ only dependency is the Ditto SDK itself. Existing environment variables
+ always win, matching python-dotenv's default behavior.
+ """
+
+ if not path.is_file():
+ return
+ for raw in path.read_text(encoding="utf-8").splitlines():
+ line = raw.strip()
+ if not line or line.startswith("#"):
+ continue
+ if line.startswith("export "):
+ line = line[len("export ") :].lstrip()
+ key, sep, value = line.partition("=")
+ if not sep:
+ continue
+ key = key.strip()
+ value = value.strip().strip('"').strip("'")
+ if key and key not in os.environ:
+ os.environ[key] = value
+
+
+def load_settings(*, offline: bool) -> Settings:
+ """Read configuration from the environment (and a local .env file)."""
+
+ # Prefer this directory's .env, then fall back to the one at the repo root
+ # that the other quickstarts share.
+ here = Path(__file__).resolve().parent
+ load_dotenv(here / ".env")
+ load_dotenv(here.parent / ".env")
+
+ # Use a temp directory for persistence, matching go-tui and rust-tui. Data
+ # is not persistent between runs, but it lets multiple instances run
+ # concurrently on the same machine — which is how the sync demo works.
+ persistence_directory = tempfile.mkdtemp(prefix="ditto-python-tui-")
+
+ if offline:
+ # No credentials required: a local-only peer with the SDK's default
+ # database ID is enough to exercise the store.
+ return Settings(
+ database_id=DittoConfig.DEFAULT_DATABASE_ID,
+ persistence_directory=persistence_directory,
+ )
+
+ database_id = os.environ.get("DITTO_DATABASE_ID", "").strip()
+ token = os.environ.get("DITTO_DEVELOPMENT_TOKEN", "").strip()
+ server_url = os.environ.get("DITTO_SERVER_URL", "").strip()
+
+ missing = [
+ name
+ for name, value in (
+ ("DITTO_DATABASE_ID", database_id),
+ ("DITTO_DEVELOPMENT_TOKEN", token),
+ ("DITTO_SERVER_URL", server_url),
+ )
+ if not value
+ ]
+ if missing:
+ raise SystemExit(
+ "Missing required environment variables: "
+ + ", ".join(missing)
+ + "\nCopy .env.sample to .env and fill in the values from "
+ "https://portal.ditto.live (or run with --smoke to try the app "
+ "locally without credentials)."
+ )
+
+ return Settings(
+ database_id=database_id,
+ persistence_directory=persistence_directory,
+ server_url=server_url,
+ development_token=token,
+ )
+
+
+def build_config(settings: Settings) -> DittoConfig:
+ connect = (
+ DittoConfigConnect.small_peers_only()
+ if settings.offline
+ else DittoConfigConnect.server(settings.server_url or "")
+ )
+ return DittoConfig(
+ database_id=settings.database_id,
+ connect=connect,
+ persistence_directory=settings.persistence_directory,
+ )
+
+
+async def authenticate(peer: Ditto, settings: Settings) -> None:
+ """Log in and keep the session refreshed.
+
+ Server connections require an expiration handler: the SDK raises
+ DittoExpirationHandlerMissingError if sync starts without one.
+ """
+
+ if settings.offline:
+ return
+
+ authenticator = peer.auth
+ if authenticator is None:
+ raise SystemExit("Server configuration did not create an authenticator.")
+
+ token = settings.development_token or ""
+
+ async def refresh(_peer: Ditto, _time_until_expiration: timedelta) -> None:
+ await authenticator.login(token, AuthenticationProvider.DEVELOPMENT)
+
+ authenticator.expiration_handler = refresh
+ await authenticator.login(token, AuthenticationProvider.DEVELOPMENT)
+
+
+class TasksApp:
+ """Owns the Ditto subscription and observer, and the tasks CRUD."""
+
+ def __init__(self, peer: Ditto, settings: Settings) -> None:
+ self.peer = peer
+ self.settings = settings
+ self.tasks: list[Task] = []
+ self._subscription: Any | None = None
+ self._observer: Any | None = None
+ # The observer callback is delivered on this event loop, so a plain
+ # Event is enough to notice that a snapshot has arrived.
+ self._updated = asyncio.Event()
+
+ async def start(self) -> None:
+ # Subscriptions request data from other peers; observers react to
+ # changes in the local store.
+ self._subscription = self.peer.sync.register_subscription(SUBSCRIPTION_QUERY)
+ self._observer = self.peer.store.register_observer(TASKS_QUERY, self._on_change)
+ if not self.settings.offline:
+ # sync.start() requires an activated instance: without a license
+ # token the SDK raises DittoError . The local store,
+ # subscriptions, and observers all work regardless — only
+ # replication with other peers needs activation.
+ self.peer.sync.start()
+ # The observer fires once immediately with the current results.
+ await self._wait_for_update(timeout=10.0)
+
+ def _on_change(self, result: QueryResult) -> None:
+ # The QueryResult owns native resources; close it when done.
+ try:
+ self.tasks = [Task.from_value(item.value) for item in result.items]
+ finally:
+ result.close()
+ self._updated.set()
+
+ async def _wait_for_update(self, timeout: float) -> bool:
+ try:
+ await asyncio.wait_for(self._updated.wait(), timeout=timeout)
+ return True
+ except asyncio.TimeoutError:
+ return False
+
+ async def _mutate(self, coro: Any) -> None:
+ """Run a write, then wait for the observer to reflect it."""
+
+ self._updated.clear()
+ await coro
+ await self._wait_for_update(timeout=2.0)
+
+ async def add(self, title: str) -> None:
+ task = {
+ "_id": str(uuid.uuid4()),
+ "title": title,
+ "done": False,
+ "deleted": False,
+ }
+ with await self.peer.store.execute(
+ "INSERT INTO tasks DOCUMENTS (:task)", {"task": task}
+ ):
+ pass
+
+ async def toggle(self, task: Task) -> None:
+ with await self.peer.store.execute(
+ "UPDATE tasks SET done = :done WHERE _id = :id",
+ {"done": not task.done, "id": task.id},
+ ):
+ pass
+
+ async def edit(self, task: Task, title: str) -> None:
+ with await self.peer.store.execute(
+ "UPDATE tasks SET title = :title WHERE _id = :id",
+ {"title": title, "id": task.id},
+ ):
+ pass
+
+ async def delete(self, task: Task) -> None:
+ # Tasks are soft-deleted so the removal syncs to other peers.
+ with await self.peer.store.execute(
+ "UPDATE tasks SET deleted = true WHERE _id = :id", {"id": task.id}
+ ):
+ pass
+
+ async def stop(self) -> None:
+ if self._observer is not None:
+ self._observer.cancel()
+ self._observer.close()
+ if self._subscription is not None:
+ self._subscription.cancel()
+ self._subscription.close()
+
+ # --- Console UI -------------------------------------------------------
+
+ def render(self) -> None:
+ mode = "offline" if self.settings.offline else "syncing"
+ print("\n" + "=" * 52)
+ print(f" Ditto Tasks ({mode})")
+ print("=" * 52)
+ if not self.tasks:
+ print(" (no tasks yet — try 'add Buy milk')")
+ else:
+ for index, task in enumerate(self.tasks, start=1):
+ box = "[x]" if task.done else "[ ]"
+ print(f" {index:>2}. {box} {task.title}")
+ print("-" * 52)
+ print(" add done edit del ")
+ print(" list help quit")
+
+ def _print_help(self) -> None:
+ print(
+ "\n add Create a task\n"
+ " done Toggle a task's completed state\n"
+ " edit Rename a task\n"
+ " del Delete a task\n"
+ " list Refresh the list\n"
+ " quit Exit"
+ )
+
+ def _resolve(self, token: str) -> Task | None:
+ try:
+ index = int(token)
+ except ValueError:
+ print(f" '{token}' is not a task number.")
+ return None
+ if 1 <= index <= len(self.tasks):
+ return self.tasks[index - 1]
+ print(f" No task #{index}.")
+ return None
+
+ async def _read(self, prompt: str) -> str:
+ # input() blocks, so run it off the event loop to keep observer
+ # callbacks flowing while we wait for the user.
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(None, lambda: input(prompt))
+
+ async def repl(self) -> None:
+ print(
+ "\nReady. Changes from other peers appear when you refresh "
+ "(press Enter or type 'list')."
+ )
+ while True:
+ self.render()
+ try:
+ line = (await self._read("\n> ")).strip()
+ except (EOFError, KeyboardInterrupt):
+ print()
+ return
+
+ if not line:
+ continue
+
+ parts = line.split(maxsplit=1)
+ command = parts[0].lower()
+ rest = parts[1].strip() if len(parts) > 1 else ""
+
+ if command in ("quit", "q", "exit"):
+ return
+ if command in ("list", "l"):
+ continue
+ if command in ("help", "h", "?"):
+ self._print_help()
+ elif command in ("add", "a"):
+ if rest:
+ await self._mutate(self.add(rest))
+ else:
+ print(" Usage: add ")
+ elif command in ("done", "toggle", "t"):
+ task = self._resolve(rest)
+ if task is not None:
+ await self._mutate(self.toggle(task))
+ elif command in ("edit", "e"):
+ edit_parts = rest.split(maxsplit=1)
+ if len(edit_parts) == 2:
+ task = self._resolve(edit_parts[0])
+ if task is not None:
+ await self._mutate(self.edit(task, edit_parts[1]))
+ else:
+ print(" Usage: edit ")
+ elif command in ("del", "delete", "rm", "d"):
+ task = self._resolve(rest)
+ if task is not None:
+ await self._mutate(self.delete(task))
+ else:
+ print(f" Unknown command '{command}'. Type 'help'.")
+
+
+async def smoke(app: TasksApp) -> None:
+ """Exercise the full CRUD path without a terminal or credentials."""
+
+ def titles() -> list[str]:
+ return [t.title for t in app.tasks]
+
+ assert titles() == [], f"expected an empty store, got {titles()}"
+ print(" ✓ opened, subscribed, observer fired with an empty result")
+
+ await app._mutate(app.add("Buy milk"))
+ assert titles() == ["Buy milk"], titles()
+ print(" ✓ insert observed:", titles())
+
+ await app._mutate(app.add("Walk the dog"))
+ assert titles() == ["Buy milk", "Walk the dog"], titles()
+ print(" ✓ second insert, ORDER BY applied:", titles())
+
+ task = app.tasks[0]
+ await app._mutate(app.toggle(task))
+ assert app.tasks[0].done is True, app.tasks[0]
+ print(" ✓ update observed: 'Buy milk' done =", app.tasks[0].done)
+
+ await app._mutate(app.edit(app.tasks[0], "Buy oat milk"))
+ assert "Buy oat milk" in titles(), titles()
+ print(" ✓ rename observed:", titles())
+
+ await app._mutate(app.delete(app.tasks[0]))
+ assert titles() == ["Walk the dog"], titles()
+ print(" ✓ soft-delete observed:", titles())
+
+ print("\n All checks passed.")
+
+
+async def run(settings: Settings, *, smoke_test: bool) -> None:
+ config = build_config(settings)
+
+ # Ditto.open() is awaitable and an async context manager; `async with`
+ # guarantees the peer is closed on the way out.
+ async with Ditto.open(config) as peer:
+ await authenticate(peer, settings)
+ app = TasksApp(peer, settings)
+ await app.start()
+ try:
+ if smoke_test:
+ await smoke(app)
+ else:
+ await app.repl()
+ finally:
+ await app.stop()
+
+
+def cli() -> None:
+ parser = argparse.ArgumentParser(description="Ditto Python quickstart — Tasks")
+ parser.add_argument(
+ "--smoke",
+ action="store_true",
+ help="run a local, credential-free CRUD self-test and exit",
+ )
+ parser.add_argument(
+ "--verbose",
+ action="store_true",
+ help="show Ditto SDK logs (suppressed by default)",
+ )
+ args = parser.parse_args()
+
+ # Ditto logs at INFO by default, which would scribble over the console UI.
+ DittoLogger.minimum_log_level = LogLevel.INFO if args.verbose else LogLevel.ERROR
+
+ settings = load_settings(offline=args.smoke)
+ try:
+ asyncio.run(run(settings, smoke_test=args.smoke))
+ except KeyboardInterrupt:
+ pass
+
+ # NOTE: deliberately not removing the persistence directory here. Ditto
+ # keeps writing to its rotating log directory — rooted at the persistence
+ # directory of the most recently opened instance — after the instance is
+ # closed, and that directory is not safe to delete until the process
+ # exits. It lives under the system temp directory, so the OS reclaims it.
+
+
+if __name__ == "__main__":
+ sys.exit(cli())
diff --git a/python-tui/pyproject.toml b/python-tui/pyproject.toml
new file mode 100644
index 000000000..a8b483450
--- /dev/null
+++ b/python-tui/pyproject.toml
@@ -0,0 +1,19 @@
+[project]
+name = "ditto-quickstart-python-tui"
+version = "0.1.0"
+description = "Ditto Python SDK quickstart — a Tasks console app that syncs peer-to-peer."
+readme = "README.md"
+requires-python = ">=3.10"
+dependencies = [
+ "dittolive-ditto>=5.2.0.dev0",
+]
+
+[project.scripts]
+ditto-tasks = "main:cli"
+
+[build-system]
+requires = ["setuptools>=61"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools]
+py-modules = ["main"]