mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-14 12:35:21 +00:00
e63c403623
* 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.
2349 lines
77 KiB
Markdown
2349 lines
77 KiB
Markdown
# 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`](./2026-05-07-simplex-chat-python-design.md)
|
|
|
|
---
|
|
|
|
## Plan structure
|
|
|
|
Eight phases, executed in order:
|
|
|
|
1. Type generation (Haskell) — `Generate/Python.hs` + `tests/APIDocs.hs` wiring.
|
|
2. Python package scaffold — `pyproject.toml`, `_version.py`, layout, hatch config.
|
|
3. Native FFI layer — `_native.py` (lazy download, ctypes, hs_init, buffer ownership).
|
|
4. Core wrapper — `core.py` (typed async FFI).
|
|
5. ChatApi — escape-hatch class with raw + ~40 high-level methods.
|
|
6. Bot class — decorators, filters, Message wrapper, lifecycle, middleware.
|
|
7. Tests + CLI — pytest suite, `python -m simplex_chat install`.
|
|
8. CI publishing — append `publish-python` job 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.hs` as starting point**
|
|
|
|
```bash
|
|
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:
|
|
```haskell
|
|
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**
|
|
|
|
```bash
|
|
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](./2026-05-07-simplex-chat-python-design.md#type-representation).
|
|
|
|
**Files:**
|
|
- Modify: `bots/src/API/Docs/Generate/Python.hs`
|
|
|
|
- [ ] **Step 1: Rewrite `typesCodeText`**
|
|
|
|
Output structure:
|
|
```python
|
|
# 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 (see `unionTypeCode` in TS).
|
|
|
|
Field-type translation (table in spec; mirrors `TypeScript.hs` `fieldsCode` `typeText`):
|
|
- `ATPrim` primitives → Python primitives (`bool`, `str`, `int`, `float`, `dict[str, object]`).
|
|
- `ATOptional t` inside TypedDict → `NotRequired[<t>]`; elsewhere `<t> | None`.
|
|
- `ATArray {elemType, nonEmpty}` → `list[<elem>]`, append `# non-empty` comment when `nonEmpty=True`.
|
|
- `ATMap (PT k) v` → `dict[<k>, <v>]`.
|
|
- `ATDef` / `ATRef` → forward-string reference `"<name>"`.
|
|
|
|
- [ ] **Step 2: Rewrite `responsesCodeText` and `eventsCodeText`**
|
|
|
|
Both produce union types — same shape as TS's `unionTypeCode`. Output structure:
|
|
|
|
```python
|
|
# 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).
|
|
|
|
```python
|
|
# 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**
|
|
|
|
```bash
|
|
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`):
|
|
|
|
```haskell
|
|
import qualified API.Docs.Generate.Python as Py
|
|
```
|
|
|
|
- [ ] **Step 2: Add four `testGenerate` calls**
|
|
|
|
Inside `apiDocsTest`, after the existing `describe "TypeScript"` block (line 40-44), add:
|
|
|
|
```haskell
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```python
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```toml
|
|
[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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```python
|
|
"""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**
|
|
|
|
```bash
|
|
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.typed` marker**
|
|
|
|
```bash
|
|
touch packages/simplex-chat-python/src/simplex_chat/py.typed
|
|
```
|
|
|
|
- [ ] **Step 2: Copy AGPL license from a sibling package**
|
|
|
|
```bash
|
|
cp packages/simplex-chat-nodejs/LICENSE packages/simplex-chat-python/LICENSE
|
|
```
|
|
|
|
- [ ] **Step 3: Write a minimal README**
|
|
|
|
```markdown
|
|
# 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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```python
|
|
"""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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```python
|
|
# 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.py` skeleton with platform + URL helpers**
|
|
|
|
```python
|
|
"""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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```python
|
|
# 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`:
|
|
|
|
```python
|
|
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**
|
|
|
|
```bash
|
|
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.)
|
|
|
|
```python
|
|
_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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```python
|
|
"""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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```python
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```python
|
|
"""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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```python
|
|
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**
|
|
|
|
```bash
|
|
# 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)` → Python `api_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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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 `Message` and aliases**
|
|
|
|
```python
|
|
"""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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```python
|
|
# 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`**
|
|
|
|
```python
|
|
# 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**
|
|
|
|
```bash
|
|
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 `Bot` class with `__init__`, decorator methods, registration storage**
|
|
|
|
```python
|
|
# 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 `MsgContent` variant list from generated types**
|
|
|
|
After Chunk 1 lands, the generated file lists every variant. Get the canonical list:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```python
|
|
# 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**
|
|
|
|
```python
|
|
# 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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```python
|
|
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**
|
|
|
|
```bash
|
|
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_event` and helpers**
|
|
|
|
```python
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```python
|
|
"""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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```python
|
|
"""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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```python
|
|
"""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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```c
|
|
// _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**
|
|
|
|
```python
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```python
|
|
"""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**
|
|
|
|
```bash
|
|
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 after `release-nodejs-libs:`)
|
|
|
|
- [ ] **Step 1: Append the job**
|
|
|
|
After line ~803 (end of `release-nodejs-libs`), add:
|
|
|
|
```yaml
|
|
# =========================
|
|
# 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**
|
|
|
|
```bash
|
|
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-chat` is available on PyPI**
|
|
|
|
```bash
|
|
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.md` documenting the release process**
|
|
|
|
Brief checklist for the maintainer:
|
|
1. Bump `_version.py` `__version__` (and `LIBS_VERSION` if libs changed).
|
|
2. Tag with `vX.Y.Z` matching `__version__`.
|
|
3. Push the tag → CI runs the existing build matrix, then `release-nodejs-libs`, then `publish-python`.
|
|
4. Verify the wheel appears at https://pypi.org/project/simplex-chat/.
|
|
|
|
- [ ] **Step 4: Commit doc update**
|
|
|
|
```bash
|
|
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-test` passes for all four `Python` test cases.
|
|
- [ ] **Python package builds.** `cd packages/simplex-chat-python && python -m build --wheel` produces 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.py` against 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-test` tag to a fork; verify the `publish-python` job runs after `release-nodejs-libs` and the wheel uploads to TestPyPI (configure a separate test publisher if doing this).
|