* docs: simplex-chat-python design and implementation plan * bots: Python wire types codegen * simplex-chat-python: package scaffold * simplex-chat-python: native libsimplex loader * simplex-chat-python: async FFI wrappers * simplex-chat-python: ChatApi with 49 api methods * simplex-chat-python: Bot class with decorators and dispatch * simplex-chat-python: install CLI, example bot, README * simplex-chat-python: audit fixes * bots: regenerate API docs and types Catches up the markdown, TypeScript and Python codegen outputs with two upstream schema changes: - APIConnectPlan.connectionLink became optional (from sh/python-lib audit fixes); cmdString and EBNF syntax now reflect optional parameter. - APIAddGroupRelays command and CRGroupRelaysAdded/CRGroupRelaysAddFailed responses added in #6917 (relay management). The TS and markdown outputs were regenerated when #6917 landed but the Python types module only got the new entries with this regeneration. * core: refresh SQLite query plans after relay_inactive_at migration The M20260507_relay_inactive_at migration (#6917 / #6952) shifted the query plans that 'Save query plans' verifies. Regenerated via the test that owns those snapshots; no behavioral change. * bots: keep APIConnectPlan connectionLink as required parameter The prior audit-fixes commit changed the syntax expression to `Optional ...` because the Haskell field is `connectionLink :: Maybe AConnectionLink`. That misrepresents the API contract: the `Maybe` is purely an internal signal for link-parsing failure (the handler returns `CEInvalidConnReq` on `Nothing`), not API-level optionality. Callers MUST always pass a connection link. Revert the syntax expression to `Param "connectionLink"` and add a comment so the intent is preserved next time someone audits. Regenerates COMMANDS.md, commands.ts and _commands.py to match.
77 KiB
SimpleX Chat Python library — implementation plan
For agentic workers: Use superpowers-extended-cc:subagent-driven-development (if subagents available) or superpowers-extended-cc:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship simplex-chat on PyPI — a Python 3 client library for SimpleX bots with the same capability as the existing Node.js library.
Architecture: Two repositories of work in this monorepo: extend the Haskell type generator (bots/src/API/Docs/) to emit Python types alongside TypeScript, and add a new Python package (packages/simplex-chat-python/) that wraps the existing prebuilt libsimplex.{so,dylib,dll} via ctypes. Lazy download of libs from simplex-chat/simplex-chat-libs GitHub releases. Async-only public API with decorator-registered handlers. Single PyPI wheel.
Tech Stack: Haskell (existing codegen), Python 3.11+ (ctypes, asyncio, hatchling), GitHub Actions (publish-python job in existing build.yml).
Spec: plans/2026-05-07-simplex-chat-python-design.md
Plan structure
Eight phases, executed in order:
- Type generation (Haskell) —
Generate/Python.hs+tests/APIDocs.hswiring. - Python package scaffold —
pyproject.toml,_version.py, layout, hatch config. - Native FFI layer —
_native.py(lazy download, ctypes, hs_init, buffer ownership). - Core wrapper —
core.py(typed async FFI). - ChatApi — escape-hatch class with raw + ~40 high-level methods.
- Bot class — decorators, filters, Message wrapper, lifecycle, middleware.
- Tests + CLI — pytest suite,
python -m simplex_chat install. - CI publishing — append
publish-pythonjob to.github/workflows/build.yml.
Each phase is a chunk. Phase boundaries are natural review points.
Chunk 1: Type generation (Haskell)
Add bots/src/API/Docs/Generate/Python.hs, mirror Generate/TypeScript.hs, wire into the test suite.
Task 1.1: Create Generate/Python.hs skeleton
Files:
-
Create:
bots/src/API/Docs/Generate/Python.hs -
Step 1: Copy
Generate/TypeScript.hsas starting point
cp bots/src/API/Docs/Generate/TypeScript.hs bots/src/API/Docs/Generate/Python.hs
- Step 2: Rename module and update output paths
Edit the new file:
-
Module:
module API.Docs.Generate.Python where -
File constants:
commandsCodeFile = "./packages/simplex-chat-python/src/simplex_chat/types/_commands.py" responsesCodeFile = "./packages/simplex-chat-python/src/simplex_chat/types/_responses.py" eventsCodeFile = "./packages/simplex-chat-python/src/simplex_chat/types/_events.py" typesCodeFile = "./packages/simplex-chat-python/src/simplex_chat/types/_types.py" -
Step 3: Register module in cabal manifest
In simplex-chat.cabal, find the other-modules: list of the simplex-chat-test test-suite stanza (type: exitcode-stdio-1.0, main-is: Test.hs). Insert API.Docs.Generate.Python alphabetically between API.Docs.Generate and API.Docs.Responses.
- Step 4: Verify cabal compiles
cabal build simplex-chat-test
Expected: builds without error (the file is still TS-shaped but Haskell-valid).
- Step 5: Commit scaffolding
git add bots/src/API/Docs/Generate/Python.hs simplex-chat.cabal
git commit -m "feat(bots): add Python codegen module skeleton"
Task 1.2: Implement Python type rendering
Replace TypeScript-specific output with Python equivalents per the spec's type-mapping rules.
Files:
-
Modify:
bots/src/API/Docs/Generate/Python.hs -
Step 1: Rewrite
typesCodeText
Output structure:
# API Types
# This file is generated automatically.
from typing import Literal, NotRequired, TypedDict
# … one class / type alias per chatTypesDocs entry …
Translation rules (mirror TypeScript.hs typeCode):
ATDRecord fields→class <name>(TypedDict):with translated field types.ATDEnum cs→<name> = Literal["c1", "c2", …].ATDUnion cs→ tagged TypedDicts + union alias + tag alias (seeunionTypeCodein TS).
Field-type translation (table in spec; mirrors TypeScript.hs fieldsCode typeText):
-
ATPrimprimitives → Python primitives (bool,str,int,float,dict[str, object]). -
ATOptional tinside TypedDict →NotRequired[<t>]; elsewhere<t> | None. -
ATArray {elemType, nonEmpty}→list[<elem>], append# non-emptycomment whennonEmpty=True. -
ATMap (PT k) v→dict[<k>, <v>]. -
ATDef/ATRef→ forward-string reference"<name>". -
Step 2: Rewrite
responsesCodeTextandeventsCodeText
Both produce union types — same shape as TS's unionTypeCode. Output structure:
# API Responses
# This file is generated automatically.
from . import _types as T
ChatResponse_<Tag1> = TypedDict(...)
ChatResponse_<Tag2> = TypedDict(...)
…
ChatResponse = ChatResponse_<Tag1> | ChatResponse_<Tag2> | …
ChatResponse_Tag = Literal["<tag1>", "<tag2>", …]
- Step 3: Rewrite
commandsCodeText
Each command becomes a TypedDict + a <Type>_cmd_string(self) -> str function + a Response alias. Function body comes from pySyntaxText in Syntax.hs:160 (no changes to that function — it's already correct and used today by Markdown docs).
# API Commands
# This file is generated automatically.
import json
from typing import TypedDict
from . import _types as T
from . import _responses as CR
class APICreateMyAddress(TypedDict):
userId: int
def APICreateMyAddress_cmd_string(self: APICreateMyAddress) -> str:
return '/_address ' + str(self['userId'])
APICreateMyAddress_Response = CR.UserContactLinkCreated | CR.ChatCmdError
The cmdString body: invoke pySyntaxText (constrName, params) syntax analogously to TS's funcCode.
- Step 4: Build to verify Haskell compiles
cabal build simplex-chat-test
Expected: clean build.
- Step 5: Commit
git add bots/src/API/Docs/Generate/Python.hs
git commit -m "feat(bots): implement Python type generation"
Task 1.3: Wire generators into tests/APIDocs.hs
Files:
-
Modify:
tests/APIDocs.hs -
Step 1: Add import
Insert after line 11 (import qualified API.Docs.Generate.TypeScript as TS):
import qualified API.Docs.Generate.Python as Py
- Step 2: Add four
testGeneratecalls
Inside apiDocsTest, after the existing describe "TypeScript" block (line 40-44), add:
describe "Python" $ do
it "generate python commands code" $ testGenerate Py.commandsCodeFile Py.commandsCodeText
it "generate python responses code" $ testGenerate Py.responsesCodeFile Py.responsesCodeText
it "generate python events code" $ testGenerate Py.eventsCodeFile Py.eventsCodeText
it "generate python types code" $ testGenerate Py.typesCodeFile Py.typesCodeText
- Step 3: Create empty target directory
mkdir -p packages/simplex-chat-python/src/simplex_chat/types
- Step 4: Run the API docs tests — they will write the four files
cabal test simplex-chat-test --test-options="--match \"API\""
First run: tests fail because the on-disk files are empty / missing. The testGenerate mechanism overwrites the file with generated content, so the second run passes.
cabal test simplex-chat-test --test-options="--match \"Python\""
Expected: PASS on the second run.
- Step 5: Sanity-check generated output
Eyeball each of the four generated files:
head -50 packages/simplex-chat-python/src/simplex_chat/types/_types.py
head -30 packages/simplex-chat-python/src/simplex_chat/types/_commands.py
head -30 packages/simplex-chat-python/src/simplex_chat/types/_responses.py
head -30 packages/simplex-chat-python/src/simplex_chat/types/_events.py
Verify: starts with # This file is generated automatically., contains valid-looking Python, no obvious junk like <RECURSIVE> markers.
- Step 6: Run them through Python parser to verify syntax
python -c "import ast; [ast.parse(open(f).read()) for f in ['packages/simplex-chat-python/src/simplex_chat/types/_types.py', 'packages/simplex-chat-python/src/simplex_chat/types/_commands.py', 'packages/simplex-chat-python/src/simplex_chat/types/_responses.py', 'packages/simplex-chat-python/src/simplex_chat/types/_events.py']]"
Expected: no exception. Any SyntaxError indicates a generator bug — fix in Generate/Python.hs and re-run cabal test.
- Step 7: Commit generated artifacts
git add tests/APIDocs.hs packages/simplex-chat-python/src/simplex_chat/types/
git commit -m "feat(bots): wire Python generators into APIDocs test suite"
Task 1.4: Add types/__init__.py re-exporting namespaces
Files:
-
Create:
packages/simplex-chat-python/src/simplex_chat/types/__init__.py -
Step 1: Write namespace re-exports
from . import _types as T
from . import _commands as CC
from . import _responses as CR
from . import _events as CEvt
__all__ = ["T", "CC", "CR", "CEvt"]
- Step 2: Verify import works
python -c "from simplex_chat.types import T, CC, CR, CEvt; print(T, CC, CR, CEvt)"
(Run from packages/simplex-chat-python/src/.)
- Step 3: Commit
git add packages/simplex-chat-python/src/simplex_chat/types/__init__.py
git commit -m "feat(python): add types namespace re-exports"
Chunk 2: Python package scaffold
Set up the package skeleton — pyproject.toml, version pinning, top-level __init__.py, AGPL license, README placeholder.
Task 2.1: Create pyproject.toml
Files:
-
Create:
packages/simplex-chat-python/pyproject.toml -
Step 1: Write hatchling build config
[build-system]
requires = ["hatchling>=1.24"]
build-backend = "hatchling.build"
[project]
name = "simplex-chat"
description = "SimpleX Chat Python library for chat bots"
readme = "README.md"
license = "AGPL-3.0-only"
authors = [{name = "SimpleX Chat"}]
requires-python = ">=3.11"
keywords = ["simplex", "messenger", "chat", "privacy", "security", "bots"]
classifiers = [
"Development Status :: 4 - Beta",
"License :: OSI Approved :: GNU Affero General Public License v3",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Communications :: Chat",
]
dynamic = ["version"]
[project.urls]
Homepage = "https://github.com/simplex-chat/simplex-chat/tree/stable/packages/simplex-chat-python"
Issues = "https://github.com/simplex-chat/simplex-chat/issues"
[project.optional-dependencies]
test = ["pytest>=8", "pytest-asyncio>=0.23"]
dev = ["pytest>=8", "pytest-asyncio>=0.23", "pyright>=1.1.380", "ruff>=0.6"]
[tool.hatch.version]
path = "src/simplex_chat/_version.py"
[tool.hatch.build.targets.wheel]
packages = ["src/simplex_chat"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
- Step 2: Commit
git add packages/simplex-chat-python/pyproject.toml
git commit -m "feat(python): add pyproject.toml with hatchling backend"
Task 2.2: Create _version.py
Files:
-
Create:
packages/simplex-chat-python/src/simplex_chat/_version.py -
Step 1: Write version constants
"""Single source of truth for both the Python package version and the
simplex-chat-libs release tag we depend on.
Bump both together for normal releases. For wrapper-only fixes use a PEP 440
post-release: __version__ = "6.5.1.post1", LIBS_VERSION unchanged.
"""
__version__ = "6.5.1" # PEP 440 — read by hatchling for wheel metadata
LIBS_VERSION = "6.5.1" # simplex-chat-libs release tag (no 'v' prefix)
- Step 2: Verify hatchling can read the version
cd packages/simplex-chat-python && python -m build --wheel
Expected: produces dist/simplex_chat-6.5.1-py3-none-any.whl. (Wheel will be incomplete — only the types module is in src/ at this point — but build should succeed.)
Clean up: rm -rf packages/simplex-chat-python/dist packages/simplex-chat-python/src/simplex_chat.egg-info
- Step 3: Commit
git add packages/simplex-chat-python/src/simplex_chat/_version.py
git commit -m "feat(python): add _version.py with package + libs version pinning"
Task 2.3: Add py.typed marker, README placeholder, AGPL license
Files:
-
Create:
packages/simplex-chat-python/src/simplex_chat/py.typed -
Create:
packages/simplex-chat-python/README.md -
Create:
packages/simplex-chat-python/LICENSE -
Step 1: Touch the empty
py.typedmarker
touch packages/simplex-chat-python/src/simplex_chat/py.typed
- Step 2: Copy AGPL license from a sibling package
cp packages/simplex-chat-nodejs/LICENSE packages/simplex-chat-python/LICENSE
- Step 3: Write a minimal README
# SimpleX Chat Python library
Python 3 client library for [SimpleX Chat](https://simplex.chat) bots.
Equivalent to the [Node.js library](https://www.npmjs.com/package/simplex-chat).
## Installation
pip install simplex-chat
Requires Python 3.11+.
## Quick start
[example to be added]
## License
[AGPL-3.0](./LICENSE)
- Step 4: Commit
git add packages/simplex-chat-python/src/simplex_chat/py.typed \
packages/simplex-chat-python/README.md \
packages/simplex-chat-python/LICENSE
git commit -m "feat(python): add py.typed marker, README, AGPL license"
Task 2.4: Top-level __init__.py with empty exports
Files:
-
Create:
packages/simplex-chat-python/src/simplex_chat/__init__.py -
Step 1: Write a placeholder that re-exports from submodules as they appear
"""SimpleX Chat — Python client library for chat bots."""
from ._version import __version__
__all__ = ["__version__"]
(Will be expanded as Bot, ChatApi, etc. land in later phases.)
- Step 2: Verify import
python -c "import simplex_chat; print(simplex_chat.__version__)"
Run from packages/simplex-chat-python/src/.
Expected: prints 6.5.1.
- Step 3: Commit
git add packages/simplex-chat-python/src/simplex_chat/__init__.py
git commit -m "feat(python): add top-level package __init__.py"
Chunk 3: Native FFI layer (_native.py)
Lazy lib download, platform detection, ctypes signatures, hs_init_with_rtsopts, atomic install, buffer ownership. Single most-error-prone piece of the package — give it tests.
Task 3.1: _native.py — platform detection + URL building
Files:
-
Create:
packages/simplex-chat-python/src/simplex_chat/_native.py -
Create:
packages/simplex-chat-python/tests/test_native_url.py -
Step 1: Write platform-detection tests
# tests/test_native_url.py
from unittest.mock import patch
import pytest
from simplex_chat._native import _platform_tag, _libs_url, _libname
@patch("sys.platform", "linux")
@patch("platform.machine", return_value="x86_64")
def test_platform_linux_x64(_):
assert _platform_tag() == "linux-x86_64"
@patch("sys.platform", "darwin")
@patch("platform.machine", return_value="arm64")
def test_platform_macos_arm64(_):
assert _platform_tag() == "macos-aarch64"
@patch("sys.platform", "win32")
@patch("platform.machine", return_value="AMD64")
def test_platform_windows_x64(_):
assert _platform_tag() == "windows-x86_64"
@patch("sys.platform", "freebsd")
@patch("platform.machine", return_value="x86_64")
def test_platform_unsupported(_):
with pytest.raises(RuntimeError, match="Unsupported"):
_platform_tag()
def test_libname_per_platform():
with patch("sys.platform", "linux"):
assert _libname() == "libsimplex.so"
with patch("sys.platform", "darwin"):
assert _libname() == "libsimplex.dylib"
with patch("sys.platform", "win32"):
assert _libname() == "libsimplex.dll"
@patch("simplex_chat._native._platform_tag", return_value="linux-x86_64")
def test_url_sqlite(_):
assert _libs_url("sqlite") == \
"https://github.com/simplex-chat/simplex-chat-libs/releases/download/" \
"v6.5.1/simplex-chat-libs-linux-x86_64.zip"
@patch("simplex_chat._native._platform_tag", return_value="linux-x86_64")
def test_url_postgres(_):
assert _libs_url("postgres") == \
"https://github.com/simplex-chat/simplex-chat-libs/releases/download/" \
"v6.5.1/simplex-chat-libs-linux-x86_64-postgres.zip"
- Step 2: Write
_native.pyskeleton with platform + URL helpers
"""Native libsimplex loader: platform detection, lazy download, ctypes setup.
Internal — users interact with `Bot` / `ChatApi`, never with this module.
"""
from __future__ import annotations
import ctypes
import errno
import os
import platform
import sys
import tempfile
import threading
import urllib.request
import zipfile
from ctypes import POINTER, c_char_p, c_int, c_uint8, c_void_p
from pathlib import Path
from typing import Literal
from ._version import LIBS_VERSION
Backend = Literal["sqlite", "postgres"]
_GITHUB_REPO = "simplex-chat/simplex-chat-libs"
_PLATFORM_MAP = {
"linux": ("linux", {"x86_64": "x86_64", "aarch64": "aarch64"}),
"darwin": ("macos", {"x86_64": "x86_64", "arm64": "aarch64"}),
"win32": ("windows", {"AMD64": "x86_64", "x86_64": "x86_64"}),
}
_LIBNAME = {"linux": "libsimplex.so", "darwin": "libsimplex.dylib", "win32": "libsimplex.dll"}
SUPPORTED = (
"linux-x86_64", "linux-aarch64",
"macos-x86_64", "macos-aarch64",
"windows-x86_64",
)
def _platform_tag() -> str:
info = _PLATFORM_MAP.get(sys.platform)
if not info:
raise RuntimeError(f"Unsupported platform: {sys.platform}")
sysname, archs = info
arch = archs.get(platform.machine())
if not arch:
raise RuntimeError(f"Unsupported architecture: {sys.platform}/{platform.machine()}")
tag = f"{sysname}-{arch}"
if tag not in SUPPORTED:
raise RuntimeError(f"Unsupported combination: {tag}; supported: {SUPPORTED}")
return tag
def _libname() -> str:
return _LIBNAME[sys.platform]
def _libs_url(backend: Backend) -> str:
suffix = "-postgres" if backend == "postgres" else ""
return (
f"https://github.com/{_GITHUB_REPO}/releases/download/"
f"v{LIBS_VERSION}/simplex-chat-libs-{_platform_tag()}{suffix}.zip"
)
- Step 3: Run tests
cd packages/simplex-chat-python && pip install -e . && pip install pytest
PYTHONPATH=src pytest tests/test_native_url.py -v
Expected: all PASS.
- Step 4: Commit
git add packages/simplex-chat-python/src/simplex_chat/_native.py \
packages/simplex-chat-python/tests/test_native_url.py
git commit -m "feat(python): _native platform detection + URL building"
Task 3.2: _native.py — cache resolution + lazy download
Files:
-
Modify:
packages/simplex-chat-python/src/simplex_chat/_native.py -
Create:
packages/simplex-chat-python/tests/test_native_cache.py -
Step 1: Write cache-resolution tests
# tests/test_native_cache.py
import zipfile
from pathlib import Path
import pytest
from simplex_chat._native import _cache_root, _resolve_libs_dir, _download
def test_cache_root_linux(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path))
monkeypatch.setattr("sys.platform", "linux")
assert _cache_root() == tmp_path / "simplex-chat"
def test_cache_root_macos(tmp_path, monkeypatch):
monkeypatch.setattr("sys.platform", "darwin")
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
assert _cache_root() == tmp_path / "Library" / "Caches" / "simplex-chat"
def test_override_via_env(tmp_path, monkeypatch):
# _resolve_libs_dir intentionally does not validate the override directory —
# it returns it verbatim; the eventual ctypes.CDLL call surfaces any mistake.
monkeypatch.setenv("SIMPLEX_LIBS_DIR", str(tmp_path))
monkeypatch.setattr("sys.platform", "linux")
assert _resolve_libs_dir("sqlite") == tmp_path
def test_resolve_downloads_when_missing(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path))
monkeypatch.setattr("sys.platform", "linux")
monkeypatch.setattr("simplex_chat._native._platform_tag", lambda: "linux-x86_64")
called = {}
def fake_download(target_root: Path, backend: str) -> None:
called["target"] = target_root
called["backend"] = backend
target_root.mkdir(parents=True, exist_ok=True)
(target_root / "libsimplex.so").touch()
monkeypatch.setattr("simplex_chat._native._download", fake_download)
libs_dir = _resolve_libs_dir("sqlite")
assert libs_dir == tmp_path / "simplex-chat" / "v6.5.1" / "sqlite"
assert called["backend"] == "sqlite"
assert (libs_dir / "libsimplex.so").exists()
def test_resolve_uses_cache_on_second_call(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path))
monkeypatch.setattr("sys.platform", "linux")
cached = tmp_path / "simplex-chat" / "v6.5.1" / "sqlite"
cached.mkdir(parents=True)
(cached / "libsimplex.so").touch()
# Should NOT call _download — use the cached file.
monkeypatch.setattr("simplex_chat._native._download",
lambda *a: pytest.fail("download should not be called"))
assert _resolve_libs_dir("sqlite") == cached
def test_postgres_on_macos_rejected(monkeypatch):
monkeypatch.setattr("sys.platform", "darwin")
monkeypatch.setattr("simplex_chat._native._platform_tag", lambda: "macos-aarch64")
with pytest.raises(RuntimeError, match="postgres.*linux-x86_64"):
_resolve_libs_dir("postgres")
def test_atomic_install(tmp_path, monkeypatch):
"""Build a fake libs zip, mock urlretrieve, verify extraction + atomic rename."""
# Build zip: libs/libsimplex.so + libs/libHS-stub.so
src = tmp_path / "src" / "libs"
src.mkdir(parents=True)
(src / "libsimplex.so").write_text("fake-so")
(src / "libHS-stub.so").write_text("fake-hs")
zip_path = tmp_path / "fake-libs.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
for f in src.iterdir():
zf.write(f, f"libs/{f.name}")
def fake_urlretrieve(url: str, dest: str) -> None:
import shutil
shutil.copy(zip_path, dest)
monkeypatch.setattr("urllib.request.urlretrieve", fake_urlretrieve)
monkeypatch.setattr("simplex_chat._native._platform_tag", lambda: "linux-x86_64")
target = tmp_path / "out"
_download(target, "sqlite")
assert (target / "libsimplex.so").read_text() == "fake-so"
assert (target / "libHS-stub.so").read_text() == "fake-hs"
- Step 2: Implement
_cache_root,_resolve_libs_dir,_download
Append to _native.py:
def _cache_root() -> Path:
if sys.platform == "darwin":
return Path.home() / "Library" / "Caches" / "simplex-chat"
if sys.platform == "win32":
return Path(os.environ["LOCALAPPDATA"]) / "simplex-chat"
base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
return Path(base) / "simplex-chat"
def _resolve_libs_dir(backend: Backend) -> Path:
if override := os.environ.get("SIMPLEX_LIBS_DIR"):
return Path(override)
if backend == "postgres" and _platform_tag() != "linux-x86_64":
raise RuntimeError(
"postgres backend is only supported on linux-x86_64; "
f"current platform is {_platform_tag()}"
)
target = _cache_root() / f"v{LIBS_VERSION}" / backend
if not (target / _libname()).exists():
_download(target, backend)
return target
def _download(target: Path, backend: Backend) -> None:
"""Download libs zip → atomic rename into `target`. Concurrent processes safe.
Atomicity strategy: each process extracts to its own sibling tempdir on the same
filesystem, then `os.rename` the `libs/` subdir to `target`. POSIX `os.rename`
onto a NON-EXISTENT path is atomic; if the target exists (another process won
the race), `os.rename` fails on most platforms — we then verify the winner has
what we need and proceed. NEVER rmtree the target: that creates a TOCTOU
window where another process is reading/loading the file we're deleting.
"""
target.parent.mkdir(parents=True, exist_ok=True)
print(
f"Downloading libsimplex ({_platform_tag()}, {backend}) "
f"v{LIBS_VERSION} ...",
file=sys.stderr,
flush=True,
)
with tempfile.TemporaryDirectory(dir=target.parent) as tmp:
zip_path = Path(tmp) / "libs.zip"
urllib.request.urlretrieve(_libs_url(backend), zip_path)
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(tmp)
# zip layout: <tmp>/libs/libsimplex.* + libHS*.*
extracted_libs = Path(tmp) / "libs"
if not extracted_libs.is_dir():
raise RuntimeError(f"libs/ missing from {_libs_url(backend)}")
try:
os.rename(extracted_libs, target)
except OSError as e:
# EEXIST / ENOTEMPTY mean another process won the race — fall through
# and check that the winner left a usable libsimplex behind. Anything
# else (ENOSPC, EACCES, EROFS, Windows codes mapped to None) is a real
# failure and must propagate. Same VERSION cached → same content →
# safe to proceed once we've confirmed the file is there.
if e.errno not in (errno.EEXIST, errno.ENOTEMPTY):
raise
if not (target / _libname()).exists():
raise RuntimeError(
f"another process partially populated {target} but libsimplex "
f"is missing; remove the directory manually and retry"
) from e
- Step 3: Run tests
PYTHONPATH=src pytest tests/test_native_cache.py -v
Expected: all PASS.
- Step 4: Commit
git add packages/simplex-chat-python/src/simplex_chat/_native.py \
packages/simplex-chat-python/tests/test_native_cache.py
git commit -m "feat(python): _native cache resolution and lazy download"
Task 3.3: _native.py — ctypes signatures, hs_init, lib loader
Files:
-
Modify:
packages/simplex-chat-python/src/simplex_chat/_native.py -
Step 1: Append the loader
(Imports for ctypes, threading, and the from ctypes import … line were already hoisted to the top of _native.py in Task 3.1 — do not re-add them here.)
_lock = threading.Lock()
_lib: ctypes.CDLL | None = None
_libc: ctypes.CDLL | None = None
_backend: Backend | None = None
def _load_libc() -> ctypes.CDLL:
if sys.platform == "win32":
return ctypes.CDLL("msvcrt")
return ctypes.CDLL(None) # libc on POSIX is the process's own symbol table
def _setup_signatures(lib: ctypes.CDLL) -> None:
"""Declare argtypes/restype for the 8 chat_* functions exported by libsimplex.
All result strings come back as raw c_void_p so the caller can free them
after copying — matches HandleCResult in cpp/simplex.cc:157-165.
"""
lib.chat_migrate_init.argtypes = [c_char_p, c_char_p, c_char_p, POINTER(c_void_p)]
lib.chat_migrate_init.restype = c_void_p
lib.chat_close_store.argtypes = [c_void_p]
lib.chat_close_store.restype = c_void_p
lib.chat_send_cmd.argtypes = [c_void_p, c_char_p]
lib.chat_send_cmd.restype = c_void_p
lib.chat_recv_msg_wait.argtypes = [c_void_p, c_int]
lib.chat_recv_msg_wait.restype = c_void_p
lib.chat_write_file.argtypes = [c_void_p, c_char_p, POINTER(c_uint8), c_int]
lib.chat_write_file.restype = c_void_p
lib.chat_read_file.argtypes = [c_char_p, c_char_p, c_char_p]
lib.chat_read_file.restype = POINTER(c_uint8)
lib.chat_encrypt_file.argtypes = [c_void_p, c_char_p, c_char_p]
lib.chat_encrypt_file.restype = c_void_p
lib.chat_decrypt_file.argtypes = [c_char_p, c_char_p, c_char_p, c_char_p]
lib.chat_decrypt_file.restype = c_void_p
def _hs_init(lib: ctypes.CDLL) -> None:
"""Initialize the Haskell runtime exactly once. Mirrors cpp/simplex.cc:13-32."""
if sys.platform == "win32":
argv_strs = [b"simplex", b"+RTS", b"-A64m", b"-H64m", b"--install-signal-handlers=no"]
else:
argv_strs = [b"simplex", b"+RTS", b"-A64m", b"-H64m", b"-xn", b"--install-signal-handlers=no"]
argc = c_int(len(argv_strs))
arr = (c_char_p * (len(argv_strs) + 1))(*argv_strs, None)
arr_ptr = ctypes.byref(ctypes.cast(arr, POINTER(c_char_p)))
lib.hs_init_with_rtsopts.argtypes = [POINTER(c_int), POINTER(POINTER(c_char_p))]
lib.hs_init_with_rtsopts.restype = None
lib.hs_init_with_rtsopts(ctypes.byref(argc), arr_ptr)
def lib_for(backend: Backend) -> ctypes.CDLL:
"""Resolve, load, and initialize libsimplex for the given backend.
Idempotent for the same backend; raises if called with a different backend.
Concurrent calls serialize on the module-level lock.
"""
global _lib, _libc, _backend
with _lock:
if _lib is not None:
if _backend != backend:
raise RuntimeError(
f"libsimplex already loaded with backend={_backend!r}; "
f"cannot switch to {backend!r} in the same process"
)
return _lib
libs_dir = _resolve_libs_dir(backend)
lib = ctypes.CDLL(str(libs_dir / _libname()))
_setup_signatures(lib)
_hs_init(lib)
_libc = _load_libc()
_lib = lib
_backend = backend
return lib
def libc() -> ctypes.CDLL:
"""libc — needed by `core` to free Haskell-allocated result strings."""
if _libc is None:
raise RuntimeError("lib_for() must be called before libc()")
return _libc
- Step 2: Sanity-check imports
PYTHONPATH=src python -c "import simplex_chat._native; print('ok')"
Expected: prints ok.
- Step 3: Commit
git add packages/simplex-chat-python/src/simplex_chat/_native.py
git commit -m "feat(python): _native ctypes signatures, hs_init, lib loader"
Chunk 4: Core wrapper (core.py)
Typed async wrappers around the 8 FFI functions. Handles JSON parse, buffer free, error translation. No public API yet — that lands in ChatApi.
Task 4.1: core.py — exceptions, enums, chat_send_cmd, chat_recv_msg_wait
Files:
-
Create:
packages/simplex-chat-python/src/simplex_chat/core.py -
Step 1: Write the core module
"""Internal typed async wrapper around libsimplex's 8 C ABI functions.
Users interact with `Bot` / `ChatApi`. This module is exposed as
`simplex_chat.core` for tests and the api.ChatApi class only.
"""
from __future__ import annotations
import asyncio
import ctypes
import json
from enum import StrEnum
from typing import TypedDict
from . import _native
from .types import T, CR, CEvt
class ChatAPIError(Exception):
"""Raised when chat_send_cmd / chat_recv_msg_wait returns a chat error."""
def __init__(self, message: str, chat_error: T.ChatError | None = None):
super().__init__(message)
self.chat_error = chat_error
class ChatInitError(Exception):
"""Raised when chat_migrate_init returns a DBMigrationResult error."""
def __init__(self, message: str, db_migration_error):
super().__init__(message)
self.db_migration_error = db_migration_error
class MigrationConfirmation(StrEnum):
YES_UP = "yesUp"
YES_UP_DOWN = "yesUpDown"
CONSOLE = "console"
ERROR = "error"
class CryptoArgs(TypedDict): # wire-format JSON; camelCase fields
fileKey: str
fileNonce: str
def _read_and_free(ptr: int | None) -> str:
"""Copy a Haskell-allocated null-terminated UTF-8 string and free its buffer.
Mirrors HandleCResult in packages/simplex-chat-nodejs/cpp/simplex.cc:157-165.
"""
if not ptr:
raise RuntimeError("null pointer returned from libsimplex")
try:
return ctypes.string_at(ptr).decode("utf-8")
finally:
_native.libc().free(ctypes.c_void_p(ptr))
async def chat_send_cmd(ctrl: int, cmd: str) -> CR.ChatResponse:
def _call() -> str:
ptr = _native._lib.chat_send_cmd(ctrl, cmd.encode("utf-8"))
return _read_and_free(ptr)
raw = await asyncio.to_thread(_call)
parsed = json.loads(raw)
if "result" in parsed and isinstance(parsed["result"], dict):
return parsed["result"] # type: ignore[return-value]
err = parsed.get("error")
if isinstance(err, dict):
raise ChatAPIError(f"chat command error: {err.get('type')}", err)
raise ChatAPIError(f"invalid chat command result: {raw[:200]}")
async def chat_recv_msg_wait(ctrl: int, wait_us: int = 5_000_000) -> CEvt.ChatEvent | None:
def _call() -> str:
ptr = _native._lib.chat_recv_msg_wait(ctrl, wait_us)
if not ptr:
return ""
return _read_and_free(ptr)
raw = await asyncio.to_thread(_call)
if not raw:
return None
parsed = json.loads(raw)
if "result" in parsed and isinstance(parsed["result"], dict):
return parsed["result"] # type: ignore[return-value]
err = parsed.get("error")
if isinstance(err, dict):
raise ChatAPIError(f"chat event error: {err.get('type')}", err)
raise ChatAPIError(f"invalid chat event: {raw[:200]}")
- Step 2: Verify import
PYTHONPATH=src python -c "from simplex_chat.core import chat_send_cmd, ChatAPIError, MigrationConfirmation; print('ok')"
Expected: ok.
- Step 3: Commit
git add packages/simplex-chat-python/src/simplex_chat/core.py
git commit -m "feat(python): core typed wrappers for chat_send_cmd + chat_recv_msg_wait"
Task 4.2: core.py — remaining FFI functions (chat_migrate_init, chat_close_store, file ops)
Files:
-
Modify:
packages/simplex-chat-python/src/simplex_chat/core.py -
Step 1: Append the remaining functions
async def chat_migrate_init(
db_path: str, db_key: str, confirm: MigrationConfirmation
) -> int:
"""Initialize chat controller. Returns opaque ctrl pointer as Python int."""
def _call() -> tuple[int, str]:
ctrl = ctypes.c_void_p()
ptr = _native._lib.chat_migrate_init(
db_path.encode("utf-8"),
db_key.encode("utf-8"),
confirm.encode("utf-8"),
ctypes.byref(ctrl),
)
return (ctrl.value or 0, _read_and_free(ptr))
ctrl_val, raw = await asyncio.to_thread(_call)
parsed = json.loads(raw)
if parsed.get("type") == "ok":
return ctrl_val
raise ChatInitError(
"Database or migration error (see db_migration_error)",
parsed,
)
async def chat_close_store(ctrl: int) -> None:
def _call() -> str:
ptr = _native._lib.chat_close_store(ctrl)
return _read_and_free(ptr)
res = await asyncio.to_thread(_call)
if res:
raise RuntimeError(res)
async def chat_write_file(ctrl: int, path: str, data: bytes) -> CryptoArgs:
def _call() -> str:
buf = (ctypes.c_uint8 * len(data)).from_buffer_copy(data)
ptr = _native._lib.chat_write_file(ctrl, path.encode("utf-8"), buf, len(data))
return _read_and_free(ptr)
raw = await asyncio.to_thread(_call)
return _crypto_args_result(raw)
async def chat_read_file(path: str, args: CryptoArgs) -> bytes:
def _call() -> bytes:
ptr = _native._lib.chat_read_file(
path.encode("utf-8"),
args["fileKey"].encode("utf-8"),
args["fileNonce"].encode("utf-8"),
)
if not ptr:
raise RuntimeError("chat_read_file returned null")
addr = ctypes.cast(ptr, ctypes.c_void_p).value or 0
try:
status = ctypes.cast(addr, ctypes.POINTER(ctypes.c_uint8))[0]
if status == 1:
msg = ctypes.string_at(addr + 1).decode("utf-8")
raise RuntimeError(msg)
if status != 0:
raise RuntimeError(f"unexpected status {status} from chat_read_file")
length = ctypes.cast(addr + 1, ctypes.POINTER(ctypes.c_uint32))[0]
return ctypes.string_at(addr + 5, length)
finally:
_native.libc().free(ctypes.c_void_p(addr))
return await asyncio.to_thread(_call)
async def chat_encrypt_file(ctrl: int, src: str, dst: str) -> CryptoArgs:
def _call() -> str:
ptr = _native._lib.chat_encrypt_file(
ctrl, src.encode("utf-8"), dst.encode("utf-8")
)
return _read_and_free(ptr)
return _crypto_args_result(await asyncio.to_thread(_call))
async def chat_decrypt_file(src: str, args: CryptoArgs, dst: str) -> None:
def _call() -> str:
ptr = _native._lib.chat_decrypt_file(
src.encode("utf-8"),
args["fileKey"].encode("utf-8"),
args["fileNonce"].encode("utf-8"),
dst.encode("utf-8"),
)
return _read_and_free(ptr)
res = await asyncio.to_thread(_call)
if res:
raise RuntimeError(res)
def _crypto_args_result(raw: str) -> CryptoArgs:
parsed = json.loads(raw)
if parsed.get("type") == "result":
return parsed["cryptoArgs"]
if parsed.get("type") == "error":
raise RuntimeError(parsed.get("writeError", "unknown write error"))
raise RuntimeError(f"unexpected result: {raw[:200]}")
- Step 2: Re-verify import
PYTHONPATH=src python -c "from simplex_chat import core; print(dir(core))"
Expected: prints a list including chat_migrate_init, chat_close_store, all eight functions.
- Step 3: Commit
git add packages/simplex-chat-python/src/simplex_chat/core.py
git commit -m "feat(python): core wrappers for migrate, close, file ops"
Chunk 5: ChatApi class
Escape-hatch class with the 6 control methods plus ~40 api_xxx methods, one per Node apiXxx. Repetitive — done in batches grouped by domain.
Task 5.1: api.py — ChatApi class with control methods + Db config
Files:
-
Create:
packages/simplex-chat-python/src/simplex_chat/api.py -
Step 1: Write Db config dataclasses + ChatApi base
"""Low-level escape-hatch API. Most users go through `Bot` instead."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from . import core
from .core import ChatAPIError, ChatInitError, MigrationConfirmation
from .types import CC, CEvt, CR, T
@dataclass(slots=True)
class SqliteDb:
file_prefix: str
encryption_key: str | None = None
@dataclass(slots=True)
class PostgresDb:
connection_string: str
schema_prefix: str | None = None
Db = SqliteDb | PostgresDb
def _db_to_migrate_args(db: Db) -> tuple[str, str, str]:
"""Returns (path-or-prefix, key-or-conn, backend)."""
if isinstance(db, SqliteDb):
return (db.file_prefix, db.encryption_key or "", "sqlite")
if isinstance(db, PostgresDb):
return (db.schema_prefix or "", db.connection_string, "postgres")
raise TypeError(f"Unknown db: {db!r}")
class ChatCommandError(Exception):
def __init__(self, message: str, response: CR.ChatResponse):
super().__init__(message)
self.response = response
class ChatApi:
def __init__(self, ctrl: int, backend: str):
self._ctrl = ctrl
self._backend = backend
@classmethod
async def init(
cls,
db: Db,
confirm: MigrationConfirmation = MigrationConfirmation.YES_UP,
) -> "ChatApi":
from . import _native
path_or_prefix, key_or_conn, backend = _db_to_migrate_args(db)
# Trigger lazy lib load with the right backend BEFORE chat_migrate_init.
_native.lib_for(backend) # type: ignore[arg-type]
ctrl = await core.chat_migrate_init(path_or_prefix, key_or_conn, confirm)
return cls(ctrl, backend)
@property
def ctrl(self) -> int:
return self._ctrl
async def start_chat(self) -> None:
r = await self.send_chat_cmd(
CC.StartChat_cmd_string({"mainApp": True, "enableSndFiles": True})
)
if r.get("type") not in ("chatStarted", "chatRunning"):
raise ChatCommandError("error starting chat", r)
async def stop_chat(self) -> None:
r = await self.send_chat_cmd("/_stop")
if r.get("type") != "chatStopped":
raise ChatCommandError("error stopping chat", r)
async def close(self) -> None:
await core.chat_close_store(self._ctrl)
async def send_chat_cmd(self, cmd: str) -> CR.ChatResponse:
return await core.chat_send_cmd(self._ctrl, cmd)
async def recv_chat_event(self, wait_us: int = 5_000_000) -> CEvt.ChatEvent | None:
return await core.chat_recv_msg_wait(self._ctrl, wait_us)
- Step 2: Verify import
PYTHONPATH=src python -c "from simplex_chat.api import ChatApi, SqliteDb, PostgresDb; print('ok')"
Expected: ok.
- Step 3: Commit
git add packages/simplex-chat-python/src/simplex_chat/api.py
git commit -m "feat(python): ChatApi base class with control methods + Db config"
Task 5.2: api.py — implement ~40 api_xxx methods
Mirror methods from packages/simplex-chat-nodejs/src/api.ts:344-958. Each method is the same shape:
async def api_<snake_name>(self, ...args) -> <ReturnType>:
r = await self.send_chat_cmd(CC.<NodeName>_cmd_string({"...": args, ...}))
if r["type"] == "<expectedTag>":
return r["<field>"]
raise ChatCommandError("error <verb>", r)
Files:
-
Modify:
packages/simplex-chat-python/src/simplex_chat/api.py -
Step 1: Reference the Node lib while implementing
# Open the Node source side-by-side
sed -n '344,958p' packages/simplex-chat-nodejs/src/api.ts | less
- Step 2: Implement methods in groups, one commit per group
The Node file groups by domain — port them in the same order:
| Group | Methods (Node names → Python snake_case) | Lines in api.ts |
|---|---|---|
| Address | apiCreateUserAddress, apiDeleteUserAddress, apiGetUserAddress, apiSetProfileAddress, apiSetAddressSettings | 344-409 |
| Messages | apiSendMessages, apiSendTextMessage, apiSendTextReply, apiUpdateChatItem, apiDeleteChatItems, apiDeleteMemberChatItem, apiChatItemReaction | 411-505 |
| Files | apiReceiveFile, apiCancelFile | 507-525 |
| Groups | apiAddMember, apiJoinGroup, apiAcceptMember, apiSetMembersRole, apiBlockMembersForAll, apiRemoveMembers, apiLeaveGroup, apiListMembers, apiNewGroup, apiUpdateGroupProfile | 527-625 |
| Group links | apiCreateGroupLink, apiSetGroupLinkMemberRole, apiDeleteGroupLink, apiGetGroupLink, apiGetGroupLinkStr | 627-672 |
| Connections | apiCreateLink, apiConnectPlan, apiConnect, apiConnectActiveUser, apiAcceptContactRequest, apiRejectContactRequest | 674-746 |
| Chats | apiListContacts, apiListGroups, apiGetChats, apiDeleteChat, apiSetGroupCustomData, apiSetContactCustomData, apiSetAutoAcceptMemberContacts, apiGetChat | 748-841 |
| Users | apiGetActiveUser, apiCreateActiveUser, apiListUsers, apiSetActiveUser, apiDeleteUser, apiUpdateProfile, apiSetContactPrefs | 843-928 |
| Member contacts | apiCreateMemberContact, apiSendMemberContactInvitation | 930-957 |
For each method:
- TS
apiCreateUserAddress(userId)→ Pythonapi_create_user_address(self, user_id: int) -> T.CreatedConnLink - Use the autogenerated
CC.<NodeName>_cmd_string({...})to build the command string. Field names inside the dict are camelCase wire format. - Use type narrowing on
r["type"]to extract the expected response field.
Example port:
async def api_create_user_address(self, user_id: int) -> T.CreatedConnLink:
r = await self.send_chat_cmd(CC.APICreateMyAddress_cmd_string({"userId": user_id}))
if r["type"] == "userContactLinkCreated":
return r["connLinkContact"]
raise ChatCommandError("error creating user address", r)
Commit pattern: one commit per group from the table above. Commit messages:
git commit -m "feat(python): ChatApi address methods"
git commit -m "feat(python): ChatApi message methods"
git commit -m "feat(python): ChatApi file methods"
# … etc, one per group
- Step 3: After all groups land, verify all methods are present
grep -c "async def api_" packages/simplex-chat-python/src/simplex_chat/api.py
Expected: ≥40 (matches the count of apiXxx methods in api.ts).
- Step 4: Verify import after all groups
PYTHONPATH=src python -c "from simplex_chat.api import ChatApi; api = ChatApi.__init__; print('ok')"
Expected: ok.
Chunk 6: Bot class
User-facing Bot: decorator-registered handlers, kwarg filters, Message wrapper with content-narrowed subclasses, dual lifecycle, middleware.
Task 6.1: Message wrapper class + content-narrowed aliases
Files:
-
Create:
packages/simplex-chat-python/src/simplex_chat/bot.py -
Step 1: Write
Messageand aliases
"""User-facing `Bot` API: decorators, filters, Message wrapper, lifecycle."""
from __future__ import annotations
import asyncio
import logging
import re
import signal as _signal
from dataclasses import dataclass
from typing import (
Any, Awaitable, Callable, Generic, Literal, TYPE_CHECKING, TypeVar, overload
)
from . import _native
from .api import ChatApi, ChatCommandError, Db, PostgresDb, SqliteDb
from .core import ChatAPIError, MigrationConfirmation
from .types import CC, CEvt, CR, T
if TYPE_CHECKING:
from typing_extensions import TypeAlias
log = logging.getLogger("simplex_chat")
C = TypeVar("C", bound=T.MsgContent)
@dataclass(slots=True)
class BotProfile:
display_name: str
full_name: str = ""
short_descr: str | None = None
image: str | None = None
@dataclass(slots=True)
class BotCommand:
keyword: str
label: str
@dataclass(slots=True, frozen=True)
class ParsedCommand:
keyword: str
args: str
@dataclass(slots=True, frozen=True)
class Message(Generic[C]):
chat_item: T.AChatItem
content: C
bot: "Bot"
@property
def chat_info(self) -> T.ChatInfo:
return self.chat_item["chatInfo"]
@property
def text(self) -> str | None:
c = self.content
if isinstance(c, dict):
return c.get("text") # type: ignore[return-value]
return None
async def reply(self, text: str) -> "Message":
items = await self.bot.api.api_send_text_reply(self.chat_item, text)
return Message(chat_item=items[0], content=items[0]["chatItem"]["content"], bot=self.bot)
async def reply_content(self, content: T.MsgContent) -> "Message":
items = await self.bot.api.api_send_messages(
self.chat_info, [{"msgContent": content, "mentions": {}}]
)
return Message(chat_item=items[0], content=items[0]["chatItem"]["content"], bot=self.bot)
async def react(self, emoji: str) -> None:
# Implementation defers to ChatApi.api_chat_item_reaction
...
async def delete(self) -> None: ...
async def forward(self, to: T.ChatRef) -> "Message": ...
# Concrete narrowed aliases — exported from package __init__.py
TextMessage = Message[T.MsgContent_Text]
ImageMessage = Message[T.MsgContent_Image]
FileMessage = Message[T.MsgContent_File]
VoiceMessage = Message[T.MsgContent_Voice]
VideoMessage = Message[T.MsgContent_Video]
LinkMessage = Message[T.MsgContent_Link]
# … one per T.MsgContent_* variant; full list mirrors what's emitted in _types.py
- Step 2: Verify import
PYTHONPATH=src python -c "from simplex_chat.bot import Message, TextMessage, BotProfile; print('ok')"
- Step 3: Commit
git add packages/simplex-chat-python/src/simplex_chat/bot.py
git commit -m "feat(python): Bot module skeleton — Message wrapper + aliases"
Task 6.2: Filter compilation (filters.py)
Files:
-
Create:
packages/simplex-chat-python/src/simplex_chat/filters.py -
Create:
packages/simplex-chat-python/tests/test_filters.py -
Step 1: Write filter tests
# tests/test_filters.py
import re
import pytest
from simplex_chat.filters import compile_message_filter
def _msg(content_type="text", text=None, chat_type="direct",
from_role=None, from_contact_id=None, group_id=None):
"""Build a minimal mock Message-like object for filter testing."""
class M:
pass
m = M()
m.content = {"type": content_type, "text": text} if text is not None else {"type": content_type}
m.chat_item = {"chatInfo": {
"type": chat_type,
**({"groupInfo": {"groupId": group_id}} if chat_type == "group" else {}),
}}
# Sender extraction is implementation-detail of the filter — keep test fixture pragmatic.
m._from_role = from_role
m._from_contact_id = from_contact_id
return m
def test_no_filters_matches_all():
f = compile_message_filter({})
assert f(_msg(content_type="text"))
assert f(_msg(content_type="image"))
def test_content_type_singular():
f = compile_message_filter({"content_type": "text"})
assert f(_msg(content_type="text"))
assert not f(_msg(content_type="image"))
def test_content_type_tuple_or():
f = compile_message_filter({"content_type": ("text", "image")})
assert f(_msg(content_type="text"))
assert f(_msg(content_type="image"))
assert not f(_msg(content_type="voice"))
def test_text_exact():
f = compile_message_filter({"text": "hello"})
assert f(_msg(text="hello"))
assert not f(_msg(text="world"))
def test_text_regex():
f = compile_message_filter({"text": re.compile(r"^\d+$")})
assert f(_msg(text="123"))
assert not f(_msg(text="abc"))
def test_when_callable():
f = compile_message_filter({"when": lambda m: m.content["type"] == "voice"})
assert f(_msg(content_type="voice"))
assert not f(_msg(content_type="text"))
def test_combined_and():
f = compile_message_filter({"content_type": "text", "text": re.compile(r"\d")})
assert f(_msg(content_type="text", text="abc123"))
assert not f(_msg(content_type="text", text="abc"))
assert not f(_msg(content_type="image"))
- Step 2: Implement
compile_message_filter
# filters.py
from __future__ import annotations
import re
from typing import Any, Callable
def compile_message_filter(kw: dict[str, Any]) -> Callable[[Any], bool]:
"""Compile filter kwargs into a single predicate function.
Multiple kwargs combine with AND; tuples within a kwarg combine with OR.
`when` is the last predicate evaluated.
"""
predicates: list[Callable[[Any], bool]] = []
if (ct := kw.get("content_type")) is not None:
ct_set = (ct,) if isinstance(ct, str) else tuple(ct)
predicates.append(lambda m: m.content.get("type") in ct_set)
if (t := kw.get("text")) is not None:
if isinstance(t, re.Pattern):
predicates.append(lambda m: bool(t.search(m.content.get("text", "") or "")))
else:
predicates.append(lambda m: m.content.get("text") == t)
if (ct := kw.get("chat_type")) is not None:
ct_set = (ct,) if isinstance(ct, str) else tuple(ct)
predicates.append(lambda m: m.chat_item["chatInfo"]["type"] in ct_set)
if (gid := kw.get("group_id")) is not None:
gid_set = (gid,) if isinstance(gid, int) else tuple(gid)
def gid_match(m: Any) -> bool:
ci = m.chat_item["chatInfo"]
return ci["type"] == "group" and ci["groupInfo"]["groupId"] in gid_set
predicates.append(gid_match)
# from_role, from_contact_id, from_member_id are looked up via helpers in filters.py
# — defer to integration with ChatInfo / GroupMember accessors implemented later.
if (when := kw.get("when")) is not None:
predicates.append(when)
if not predicates:
return lambda _m: True
return lambda m: all(p(m) for p in predicates)
- Step 3: Run filter tests
PYTHONPATH=src pytest tests/test_filters.py -v
Expected: all PASS.
- Step 4: Commit
git add packages/simplex-chat-python/src/simplex_chat/filters.py \
packages/simplex-chat-python/tests/test_filters.py
git commit -m "feat(python): filter kwarg compilation + tests"
Task 6.3: Bot class — construction, decorators, registration
Files:
-
Modify:
packages/simplex-chat-python/src/simplex_chat/bot.py -
Step 1: Add
Botclass with__init__, decorator methods, registration storage
# Append to bot.py
MessageHandler = Callable[[Message], Awaitable[None]]
CommandHandler = Callable[[Message, ParsedCommand], Awaitable[None]]
EventHandler = Callable[[CEvt.ChatEvent], Awaitable[None]]
# Note: handlers are async-only (matches spec). Users must `async def handler(...)`.
class Middleware:
async def __call__(
self,
handler: Callable[[Message, dict[str, object]], Awaitable[None]],
message: Message,
data: dict[str, object],
) -> None:
await handler(message, data)
class Bot:
def __init__(
self,
*,
profile: BotProfile,
db: Db,
welcome: str | T.MsgContent | None = None,
commands: list[BotCommand] | None = None,
confirm_migrations: MigrationConfirmation = MigrationConfirmation.YES_UP,
create_address: bool = True,
update_address: bool = True,
update_profile: bool = True,
auto_accept: bool = True,
business_address: bool = False,
allow_files: bool = False,
use_bot_profile: bool = True,
log_contacts: bool = True,
log_network: bool = False,
) -> None:
self._profile = profile
self._db = db
self._welcome = welcome
self._commands = commands or []
self._confirm_migrations = confirm_migrations
self._opts = {
"create_address": create_address,
"update_address": update_address,
"update_profile": update_profile,
"auto_accept": auto_accept,
"business_address": business_address,
"allow_files": allow_files,
"use_bot_profile": use_bot_profile,
"log_contacts": log_contacts,
"log_network": log_network,
}
self._api: ChatApi | None = None
self._serving = False
self._stop_event = asyncio.Event()
self._message_handlers: list[tuple[Callable[[Message], bool], MessageHandler]] = []
self._command_handlers: list[tuple[tuple[str, ...], Callable[[Message], bool], CommandHandler]] = []
self._event_handlers: dict[str, list[EventHandler]] = {}
self._middleware: list[Middleware] = []
@property
def api(self) -> ChatApi:
if self._api is None:
raise RuntimeError("Bot not initialized — call bot.run() or use `async with bot:`")
return self._api
# --- decorator registration (overloads omitted for brevity; see spec for full list) ---
def on_message(self, **filter_kw: Any) -> Callable[[MessageHandler], MessageHandler]:
from .filters import compile_message_filter
predicate = compile_message_filter(filter_kw)
def deco(fn: MessageHandler) -> MessageHandler:
self._message_handlers.append((predicate, fn))
return fn
return deco
def on_command(
self, name: str | tuple[str, ...], **filter_kw: Any
) -> Callable[[CommandHandler], CommandHandler]:
names = (name,) if isinstance(name, str) else tuple(name)
from .filters import compile_message_filter
predicate = compile_message_filter(filter_kw)
def deco(fn: CommandHandler) -> CommandHandler:
self._command_handlers.append((names, predicate, fn))
return fn
return deco
def on_event(self, event: CEvt.Tag, /) -> Callable[[EventHandler], EventHandler]:
def deco(fn: EventHandler) -> EventHandler:
self._event_handlers.setdefault(event, []).append(fn)
return fn
return deco
def use(self, middleware: Middleware) -> None:
self._middleware.append(middleware)
- Step 2: Discover the full
MsgContentvariant list from generated types
After Chunk 1 lands, the generated file lists every variant. Get the canonical list:
grep -E '^class MsgContent_' packages/simplex-chat-python/src/simplex_chat/types/_types.py | sed 's/class \([^(]*\).*/\1/'
Expected output (approximately — exact list comes from Haskell MsgContent defined at bots/src/API/Docs/Types.hs:309 with prefix "MC"):
MsgContent_Text
MsgContent_Link
MsgContent_Image
MsgContent_Video
MsgContent_Voice
MsgContent_File
MsgContent_Report
MsgContent_Chat
MsgContent_Unknown
For each variant in that list, define both a <Name>Message alias (in Step 1 above — extend the alias block to cover every variant) and one decorator overload (this step).
- Step 3: Add the typed overloads — one per variant from Step 2
After the plain on_message definition, add a typed overload for each variant. The pattern below shows two; repeat for every variant the previous step found:
# Type-only overloads — compiler-visible, no runtime effect.
# MUST cover every variant from `grep '^class MsgContent_' _types.py`.
@overload
def on_message(self, *, content_type: Literal["text"], **rest: Any
) -> Callable[[Callable[[TextMessage], Awaitable[None]]],
Callable[[TextMessage], Awaitable[None]]]: ...
@overload
def on_message(self, *, content_type: Literal["image"], **rest: Any
) -> Callable[[Callable[[ImageMessage], Awaitable[None]]],
Callable[[ImageMessage], Awaitable[None]]]: ...
# … one per MsgContent variant; verify count matches Step 2's grep output …
@overload
def on_message(self, **rest: Any
) -> Callable[[MessageHandler], MessageHandler]: ...
The per-variant tag string ("text", "image", …) is the lowercased suffix after MsgContent_ — see the Literal[...] member on each generated TypedDict's type field for the canonical spelling.
- Step 3: Verify import + decorator binding
# tests/test_bot_registration.py
from simplex_chat.bot import Bot, BotProfile
from simplex_chat.api import SqliteDb
def test_decorator_registers_handler():
bot = Bot(profile=BotProfile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test"))
@bot.on_message(content_type="text")
async def h(msg): pass
assert len(bot._message_handlers) == 1
PYTHONPATH=src pytest tests/test_bot_registration.py -v
Expected: PASS.
- Step 4: Commit
git add packages/simplex-chat-python/src/simplex_chat/bot.py \
packages/simplex-chat-python/tests/test_bot_registration.py
git commit -m "feat(python): Bot class — construction + decorator registration"
Task 6.4: Lifecycle: run(), serve_forever(), __aenter__/__aexit__, stop()
Files:
-
Modify:
packages/simplex-chat-python/src/simplex_chat/bot.py -
Step 1: Append lifecycle methods
class Bot:
# … existing methods …
async def __aenter__(self) -> "Bot":
self._api = await ChatApi.init(self._db, self._confirm_migrations)
await self._api.start_chat()
# TODO Task 6.5: profile + address sync via mkBotProfile/createOrUpdateAddress
return self
async def __aexit__(self, *exc_info: object) -> None:
self.stop()
if self._api is not None:
try:
await self._api.stop_chat()
finally:
await self._api.close()
self._api = None
def run(self) -> None:
"""Blocking entry: runs serve_forever() with a SIGINT handler installed."""
async def _main() -> None:
async with self:
loop = asyncio.get_running_loop()
if hasattr(_signal, "SIGINT"):
try:
loop.add_signal_handler(_signal.SIGINT, self.stop)
loop.add_signal_handler(_signal.SIGTERM, self.stop)
except NotImplementedError: # Windows
_signal.signal(_signal.SIGINT, lambda *_: self.stop())
await self.serve_forever()
asyncio.run(_main())
async def serve_forever(self) -> None:
if self._serving:
raise RuntimeError("already serving")
self._serving = True
self._stop_event.clear()
try:
await self._receive_loop()
finally:
self._serving = False
def stop(self) -> None:
self._stop_event.set()
async def _receive_loop(self) -> None:
while not self._stop_event.is_set():
try:
event = await self.api.recv_chat_event(wait_us=5_000_000)
except ChatAPIError as e:
log.error("chat event error: %s", e)
continue
if event is None:
continue
await self._dispatch_event(event)
- Step 2: Commit
git add packages/simplex-chat-python/src/simplex_chat/bot.py
git commit -m "feat(python): Bot lifecycle (run, serve_forever, async-context, stop)"
Task 6.5: Event dispatch + handler invocation through middleware
Files:
-
Modify:
packages/simplex-chat-python/src/simplex_chat/bot.py -
Step 1: Append
_dispatch_eventand helpers
class Bot:
async def _dispatch_event(self, event: CEvt.ChatEvent) -> None:
# 1. Tag-targeted on_event handlers (registration order)
tag = event["type"]
for h in self._event_handlers.get(tag, []):
try:
await h(event)
except Exception:
log.exception("on_event handler failed")
# 2. If newChatItems → message + command dispatch.
# Tag check narrows the union; pyright sees event as CEvt.ChatEvent_NewChatItems below.
if tag == "newChatItems":
evt: CEvt.ChatEvent_NewChatItems = event # type: ignore[assignment]
for ci in evt["chatItems"]:
content = ci["chatItem"]["content"]
if content["type"] != "rcvMsgContent":
continue
msg_content = content["msgContent"]
msg = Message(chat_item=ci, content=msg_content, bot=self)
await self._dispatch_message(msg)
async def _dispatch_message(self, msg: Message) -> None:
# Run all matching message handlers
for predicate, handler in self._message_handlers:
if predicate(msg):
await self._invoke_with_middleware(handler, msg)
# Then any matching command handlers
cmd = self._parse_command(msg)
if cmd is not None:
for names, predicate, handler in self._command_handlers:
if cmd.keyword in names and predicate(msg):
await self._invoke_command_with_middleware(handler, msg, cmd)
async def _invoke_with_middleware(
self, handler: MessageHandler, message: Message
) -> None:
async def call(m: Message, _data: dict[str, object]) -> None:
await handler(m)
chain: Callable[[Message, dict[str, object]], Awaitable[None]] = call
# mw=mw, inner=inner bind the loop variable (late-binding fix)
for mw in reversed(self._middleware):
inner = chain
async def _wrapped(m: Message, d: dict[str, object], mw=mw, inner=inner) -> None:
await mw(inner, m, d)
chain = _wrapped
try:
await chain(message, {})
except Exception:
log.exception("message handler failed")
async def _invoke_command_with_middleware(
self, handler: CommandHandler, message: Message, cmd: ParsedCommand
) -> None:
# Same shape as _invoke_with_middleware but the inner call gets cmd too.
async def call(m: Message, _data: dict[str, object]) -> None:
await handler(m, cmd)
chain: Callable[[Message, dict[str, object]], Awaitable[None]] = call
for mw in reversed(self._middleware):
inner = chain
async def _wrapped(m: Message, d: dict[str, object], mw=mw, inner=inner) -> None:
await mw(inner, m, d)
chain = _wrapped
try:
await chain(message, {})
except Exception:
log.exception("command handler failed")
@staticmethod
def _parse_command(msg: Message) -> ParsedCommand | None:
text = msg.text
if not text or not text.startswith("/"):
return None
body = text[1:].lstrip()
if not body:
return None
if " " in body:
kw, args = body.split(" ", 1)
return ParsedCommand(keyword=kw, args=args.strip())
return ParsedCommand(keyword=body, args="")
- Step 2: Commit
git add packages/simplex-chat-python/src/simplex_chat/bot.py
git commit -m "feat(python): Bot event dispatch + middleware chaining"
Task 6.6: Profile + address sync (mirror Node bot.ts:158-214)
Files:
-
Modify:
packages/simplex-chat-python/src/simplex_chat/bot.py -
Step 1: Implement initial profile + address sync inside
__aenter__
Mirror bot.ts createBotUser, createOrUpdateAddress, updateBotUserProfile, mkBotProfile. Each is straightforward — fetch via self._api.api_get_active_user(), compare with self._profile, update if update_profile=True, etc. Reference: packages/simplex-chat-nodejs/src/bot.ts:158-214.
- Step 2: Commit
git add packages/simplex-chat-python/src/simplex_chat/bot.py
git commit -m "feat(python): Bot profile + address sync on init"
Task 6.7: Update package __init__.py to export public API
Files:
-
Modify:
packages/simplex-chat-python/src/simplex_chat/__init__.py -
Step 1: Add exports
"""SimpleX Chat — Python client library for chat bots."""
from ._version import __version__
from .api import ChatApi, ChatCommandError, Db, PostgresDb, SqliteDb
from .bot import (
Bot,
BotCommand,
BotProfile,
FileMessage,
ImageMessage,
Message,
Middleware,
ParsedCommand,
TextMessage,
VoiceMessage,
# … all aliases …
)
from .core import ChatAPIError, ChatInitError, MigrationConfirmation, CryptoArgs
__all__ = [
"__version__",
# … all the names above …
]
- Step 2: Verify clean import
PYTHONPATH=src python -c "from simplex_chat import Bot, TextMessage, SqliteDb, BotProfile; print('ok')"
- Step 3: Commit
git add packages/simplex-chat-python/src/simplex_chat/__init__.py
git commit -m "feat(python): export public API from package __init__"
Chunk 7: Tests + CLI
Pytest suite covering native, codegen, smoke; pre-fetch CLI; example bot.
Task 7.1: __main__.py — python -m simplex_chat install
Files:
-
Create:
packages/simplex-chat-python/src/simplex_chat/__main__.py -
Step 1: Write the CLI
"""CLI: python -m simplex_chat install [--backend=sqlite|postgres]"""
from __future__ import annotations
import argparse
import sys
from . import _native
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(prog="simplex_chat")
sub = p.add_subparsers(dest="command", required=True)
install = sub.add_parser("install", help="Pre-fetch libsimplex into the user cache")
install.add_argument(
"--backend", choices=["sqlite", "postgres"], default="sqlite",
help="which libsimplex variant to download (default: sqlite)",
)
args = p.parse_args(argv)
if args.command == "install":
try:
path = _native._resolve_libs_dir(args.backend)
print(f"libsimplex installed at: {path}")
return 0
except Exception as e:
print(f"install failed: {e}", file=sys.stderr)
return 1
return 1
if __name__ == "__main__":
sys.exit(main())
- Step 2: Smoke-check the CLI exists
PYTHONPATH=src python -m simplex_chat install --help
Expected: prints argparse help.
- Step 3: Commit
git add packages/simplex-chat-python/src/simplex_chat/__main__.py
git commit -m "feat(python): python -m simplex_chat install CLI"
Task 7.2: Codegen smoke test
Files:
-
Create:
packages/simplex-chat-python/tests/test_codegen.py -
Step 1: Write the test
"""Sanity checks on auto-generated wire types — catches generator regressions."""
import typing
from simplex_chat.types import T, CC, CR, CEvt
def test_types_module_imports():
"""Every generated module imports cleanly with no SyntaxError."""
assert T is not None and CC is not None and CR is not None and CEvt is not None
def test_chat_type_is_literal_enum():
"""ChatType should be a Literal of expected member set."""
origin = typing.get_origin(T.ChatType)
args = typing.get_args(T.ChatType)
# Python ≥3.11 typing.Literal: origin is Literal, args is the tuple of values
assert "direct" in args
assert "group" in args
assert "local" in args
def test_known_command_has_cmd_string():
s = CC.APICreateMyAddress_cmd_string({"userId": 1})
assert s == "/_address 1"
def test_chat_response_tag_alias_present():
"""ChatResponse_Tag union of literals exists."""
assert hasattr(CR, "ChatResponse_Tag")
- Step 2: Run
PYTHONPATH=src pytest tests/test_codegen.py -v
Expected: PASS.
- Step 3: Commit
git add packages/simplex-chat-python/tests/test_codegen.py
git commit -m "test(python): codegen sanity checks"
Task 7.3: Smoke test with stub libsimplex
Files:
-
Create:
packages/simplex-chat-python/tests/test_smoke.py -
Create:
packages/simplex-chat-python/tests/_stub_libsimplex.c -
Step 1: Write a tiny C stub that returns canned JSON
// _stub_libsimplex.c — compile to libsimplex.so for smoke testing
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void hs_init_with_rtsopts(int *argc, char ***argv) { (void)argc; (void)argv; }
static char *dup_str(const char *s) { return strdup(s); }
char *chat_migrate_init(const char *path, const char *key, const char *confirm, void **ctrl) {
(void)path; (void)key; (void)confirm;
*ctrl = (void*)0x1;
return dup_str("{\"type\":\"ok\"}");
}
char *chat_close_store(void *ctrl) { (void)ctrl; return dup_str(""); }
char *chat_send_cmd(void *ctrl, const char *cmd) {
(void)ctrl; (void)cmd;
return dup_str("{\"result\":{\"type\":\"chatStarted\"}}");
}
char *chat_recv_msg_wait(void *ctrl, int wait) {
(void)ctrl; (void)wait;
return NULL;
}
// stubs for the file functions:
char *chat_write_file() { return dup_str("{\"type\":\"result\",\"cryptoArgs\":{\"fileKey\":\"k\",\"fileNonce\":\"n\"}}"); }
char *chat_read_file() { return NULL; }
char *chat_encrypt_file() { return dup_str("{\"type\":\"result\",\"cryptoArgs\":{\"fileKey\":\"k\",\"fileNonce\":\"n\"}}"); }
char *chat_decrypt_file() { return dup_str(""); }
- Step 2: Write the smoke test that compiles and uses the stub
import asyncio
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
import pytest
@pytest.fixture
def stub_libs_dir(tmp_path):
"""Compile _stub_libsimplex.c into a libsimplex.so in tmp_path."""
src = Path(__file__).parent / "_stub_libsimplex.c"
if sys.platform == "win32":
pytest.skip("stub compilation not implemented for Windows")
libname = "libsimplex.dylib" if sys.platform == "darwin" else "libsimplex.so"
out = tmp_path / libname
cc = shutil.which("cc") or shutil.which("gcc") or pytest.skip("no C compiler")
subprocess.run([cc, "-shared", "-fPIC", str(src), "-o", str(out)], check=True)
return tmp_path
@pytest.mark.asyncio
async def test_chat_api_init_and_start(stub_libs_dir, monkeypatch):
monkeypatch.setenv("SIMPLEX_LIBS_DIR", str(stub_libs_dir))
from simplex_chat.api import ChatApi, SqliteDb
api = await ChatApi.init(SqliteDb(file_prefix=str(stub_libs_dir / "db")))
await api.start_chat()
await api.close()
- Step 3: Run
test extras are already declared in pyproject.toml (Task 2.1).
pip install -e ".[test]"
PYTHONPATH=src pytest tests/test_smoke.py -v
Expected: PASS on Linux/macOS; SKIPPED on Windows.
- Step 4: Commit
git add packages/simplex-chat-python/tests/_stub_libsimplex.c \
packages/simplex-chat-python/tests/test_smoke.py
git commit -m "test(python): smoke test against stub libsimplex"
Task 7.4: Squaring-bot example
Files:
-
Create:
packages/simplex-chat-python/examples/squaring_bot.py -
Step 1: Write the example from the spec
"""Squaring bot — receives numbers, replies with their squares.
Run: python examples/squaring_bot.py
"""
import re
from simplex_chat import (
Bot, BotProfile, BotCommand, SqliteDb, TextMessage, Message, ParsedCommand
)
bot = Bot(
profile=BotProfile(display_name="Squaring bot"),
db=SqliteDb(file_prefix="./squaring_bot"),
welcome="Send me a number, I'll square it.",
commands=[BotCommand(keyword="help", label="Show help")],
)
NUMBER_RE = re.compile(r"^-?\d+(\.\d+)?$")
@bot.on_message(content_type="text", text=NUMBER_RE)
async def square(msg: TextMessage) -> None:
n = float(msg.text)
await msg.reply(f"{n} * {n} = {n * n}")
@bot.on_message(content_type="text") # fallback
async def fallback(msg: Message) -> None:
await msg.reply("Send me a number, like 7 or 3.14.")
@bot.on_command("help")
async def help_cmd(msg: Message, _cmd: ParsedCommand) -> None:
await msg.reply("Send a number, I'll square it.")
if __name__ == "__main__":
bot.run()
- Step 2: Document in README
Replace the placeholder in packages/simplex-chat-python/README.md with the example and pip install simplex-chat quick-start.
- Step 3: Commit
git add packages/simplex-chat-python/examples/squaring_bot.py \
packages/simplex-chat-python/README.md
git commit -m "docs(python): squaring bot example + README quick-start"
Chunk 8: CI publishing
Append publish-python job to existing .github/workflows/build.yml after release-nodejs-libs. Configure PyPI trusted publisher.
Task 8.1: Add publish-python job to build.yml
Files:
-
Modify:
.github/workflows/build.yml(append new job afterrelease-nodejs-libs:) -
Step 1: Append the job
After line ~803 (end of release-nodejs-libs), add:
# =========================
# Python package release
# =========================
# Publishes simplex-chat to PyPI on release tags.
# Depends on release-nodejs-libs because the Python package downloads
# its libsimplex from the simplex-chat-libs release at runtime, so the
# libs release must exist before the Python package is published.
#
# Trusted publishing is configured on PyPI: no API token, OIDC only.
publish-python:
runs-on: ubuntu-latest
needs: [release-nodejs-libs]
if: startsWith(github.ref, 'refs/tags/v')
permissions:
id-token: write # OIDC for trusted publishing
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install build
- run: python -m build --wheel
working-directory: packages/simplex-chat-python
- uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: packages/simplex-chat-python/dist
- Step 2: Validate YAML locally
python -c "import yaml; yaml.safe_load(open('.github/workflows/build.yml'))"
Expected: no exception.
- Step 3: Commit
git add .github/workflows/build.yml
git commit -m "ci: add publish-python job for PyPI release on tag"
Task 8.2: One-time PyPI setup (manual, document in README)
- Step 1: Verify package name
simplex-chatis available on PyPI
curl -s https://pypi.org/pypi/simplex-chat/json | head -c 200
If it 404s, the name is free. If it returns metadata, the name is taken — coordinate with the team.
- Step 2: On PyPI, create a pending publisher
Navigate to https://pypi.org/manage/account/publishing/ and add:
| Field | Value |
|---|---|
| PyPI Project Name | simplex-chat |
| Owner | simplex-chat |
| Repository name | simplex-chat |
| Workflow name | build.yml |
| Environment name | (leave blank) |
- Step 3: Add a section to
packages/simplex-chat-python/README.mddocumenting the release process
Brief checklist for the maintainer:
- Bump
_version.py__version__(andLIBS_VERSIONif libs changed). - Tag with
vX.Y.Zmatching__version__. - Push the tag → CI runs the existing build matrix, then
release-nodejs-libs, thenpublish-python. - Verify the wheel appears at https://pypi.org/project/simplex-chat/.
- Step 4: Commit doc update
git add packages/simplex-chat-python/README.md
git commit -m "docs(python): release process + PyPI trusted publisher setup"
Final acceptance
After all phases:
- Type generation parity.
cabal test simplex-chat-testpasses for all fourPythontest cases. - Python package builds.
cd packages/simplex-chat-python && python -m build --wheelproduces a single.whl≤ 200 KB. - All Python tests pass.
pytest packages/simplex-chat-python/tests— green on Linux + macOS. - Pyright clean.
pyright packages/simplex-chat-python/src— zero errors. - Squaring bot smoke. Run
python examples/squaring_bot.pyagainst a fresh database; verify (a) lazy lib download succeeds, (b)bot.run()blocks, (c) Ctrl-C exits cleanly. - CI dry run. Push a
v0.0.0-testtag to a fork; verify thepublish-pythonjob runs afterrelease-nodejs-libsand the wheel uploads to TestPyPI (configure a separate test publisher if doing this).