Add async python client for Delta Chat core JSON-RPC

This commit is contained in:
link2xt
2022-11-05 17:55:45 +00:00
parent f2c97bda66
commit 9b04a04568
14 changed files with 546 additions and 0 deletions

View File

@@ -147,6 +147,20 @@ jobs:
working-directory: python
run: tox -e lint,mypy,doc,py3
- name: build deltachat-rpc-server
if: ${{ matrix.python }}
uses: actions-rs/cargo@v1
with:
command: build
args: -p deltachat-rpc-server
- name: run deltachat-rpc-client tests
if: ${{ matrix.python }}
env:
DCC_NEW_TMP_EMAIL: ${{ secrets.DCC_NEW_TMP_EMAIL }}
working-directory: deltachat-rpc-client
run: tox -e py3
- name: install pypy
if: ${{ matrix.python }}
uses: actions/setup-python@v4

View File

@@ -9,6 +9,7 @@
### API-Changes
- Add Python API to send reactions #3762
- jsonrpc: add message errors to MessageObject #3788
- jsonrpc: Add async Python client #3734
### Fixes
- Make sure malformed messsages will never block receiving further messages anymore #3771

View File

@@ -0,0 +1,33 @@
# Delta Chat RPC python client
RPC client connects to standalone Delta Chat RPC server `deltachat-rpc-server`
and provides asynchronous interface to it.
## Getting started
To use Delta Chat RPC client, first build a `deltachat-rpc-server` with `cargo build -p deltachat-rpc-server`.
Install it anywhere in your `PATH`.
## Testing
1. Build `deltachat-rpc-server` with `cargo build -p deltachat-rpc-server`.
2. Run `tox`.
Additional arguments to `tox` are passed to pytest, e.g. `tox -- -s` does not capture test output.
## Using in REPL
It is recommended to use IPython, because it supports using `await` directly
from the REPL.
```
PATH="../target/debug:$PATH" ipython
...
In [1]: from deltachat_rpc_client import *
In [2]: dc = Deltachat(await start_rpc_server())
In [3]: await dc.get_all_accounts()
Out [3]: []
In [4]: alice = await dc.add_account()
In [5]: (await alice.get_info())["journal_mode"]
Out [5]: 'wal'
```

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env python3
import asyncio
import logging
import sys
import deltachat_rpc_client as dc
async def main():
rpc = await dc.start_rpc_server()
deltachat = dc.Deltachat(rpc)
system_info = await deltachat.get_system_info()
logging.info("Running deltachat core %s", system_info["deltachat_core_version"])
accounts = await deltachat.get_all_accounts()
account = accounts[0] if accounts else await deltachat.add_account()
await account.set_config("bot", "1")
if not await account.is_configured():
logging.info("Account is not configured, configuring")
await account.set_config("addr", sys.argv[1])
await account.set_config("mail_pw", sys.argv[2])
await account.configure()
logging.info("Configured")
else:
logging.info("Account is already configured")
await deltachat.start_io()
async def process_messages():
fresh_messages = await account.get_fresh_messages()
fresh_message_snapshot_tasks = [
message.get_snapshot() for message in fresh_messages
]
fresh_message_snapshots = await asyncio.gather(*fresh_message_snapshot_tasks)
for snapshot in reversed(fresh_message_snapshots):
if not snapshot.is_info:
await snapshot.chat.send_text(snapshot.text)
await snapshot.message.mark_seen()
# Process old messages.
await process_messages()
while True:
event = await account.wait_for_event()
if event["type"] == "Info":
logging.info("%s", event["msg"])
elif event["type"] == "Warning":
logging.warning("%s", event["msg"])
elif event["type"] == "Error":
logging.error("%s", event["msg"])
elif event["type"] == "IncomingMsg":
logging.info("Got an incoming message")
await process_messages()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
asyncio.run(main())

View File

@@ -0,0 +1,14 @@
[build-system]
requires = ["setuptools>=45", "setuptools_scm[toml]>=6.2"]
build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-client"
description = "Python client for Delta Chat core JSON-RPC interface"
dependencies = [
"aiohttp",
"aiodns"
]
dynamic = [
"version"
]

View File

@@ -0,0 +1,5 @@
from .account import Account
from .contact import Contact
from .deltachat import Deltachat
from .message import Message
from .rpc import Rpc, new_online_account, start_rpc_server

View File

@@ -0,0 +1,68 @@
from typing import Optional
from .chat import Chat
from .contact import Contact
from .message import Message
class Account:
def __init__(self, rpc, account_id):
self.rpc = rpc
self.account_id = account_id
def __repr__(self):
return "<Account id={}>".format(self.account_id)
async def wait_for_event(self):
"""Wait until the next event and return it."""
return await self.rpc.wait_for_event(self.account_id)
async def remove(self) -> None:
"""Remove the account."""
await self.rpc.remove_account(self.account_id)
async def start_io(self) -> None:
"""Start the account I/O."""
await self.rpc.start_io(self.account_id)
async def stop_io(self) -> None:
"""Stop the account I/O."""
await self.rpc.stop_io(self.account_id)
async def get_info(self):
return await self.rpc.get_info(self.account_id)
async def get_file_size(self):
return await self.rpc.get_account_file_size(self.account_id)
async def is_configured(self) -> bool:
"""Return True for configured accounts."""
return await self.rpc.is_configured(self.account_id)
async def set_config(self, key: str, value: Optional[str]):
"""Set the configuration value key pair."""
await self.rpc.set_config(self.account_id, key, value)
async def get_config(self, key: str) -> Optional[str]:
"""Get the configuration value."""
return await self.rpc.get_config(self.account_id, key)
async def configure(self):
"""Configure an account."""
await self.rpc.configure(self.account_id)
async def create_contact(self, address: str, name: Optional[str]) -> Contact:
"""Create a contact with the given address and, optionally, a name."""
return Contact(
self.rpc,
self.account_id,
await self.rpc.create_contact(self.account_id, address, name),
)
async def secure_join(self, qr: str) -> Chat:
chat_id = await self.rpc.secure_join(self.account_id, qr)
return Chat(self.rpc, self.account_id, self.chat_id)
async def get_fresh_messages(self):
fresh_msg_ids = await self.rpc.get_fresh_msgs(self.account_id)
return [Message(self.rpc, self.account_id, msg_id) for msg_id in fresh_msg_ids]

View File

@@ -0,0 +1,33 @@
class Chat:
def __init__(self, rpc, account_id, chat_id):
self.rpc = rpc
self.account_id = account_id
self.chat_id = chat_id
async def block(self):
"""Block the chat."""
await self.rpc.block_chat(self.account_id, self.chat_id)
async def accept(self):
"""Accept the contact request."""
await self.rpc.accept_chat(self.account_id, self.chat_id)
async def delete(self):
await self.rpc.delete_chat(self.account_id, self.chat_id)
async def get_encryption_info(self):
await self.rpc.get_chat_encryption_info(self.account_id, self.chat_id)
async def send_text(self, text: str):
from .message import Message
msg_id = await self.rpc.misc_send_text_message(
self.account_id, self.chat_id, text
)
return Message(self.rpc, self.account_id, msg_id)
async def leave(self):
await self.rpc.leave_group(self.account_id, self.chat_id)
async def get_fresh_message_count() -> int:
await get_fresh_msg_cnt(self.account_id, self.chat_id)

View File

@@ -0,0 +1,44 @@
class Contact:
"""
Contact API.
Essentially a wrapper for RPC, account ID and a contact ID.
"""
def __init__(self, rpc, account_id, contact_id):
self.rpc = rpc
self.account_id = account_id
self.contact_id = contact_id
async def block(self):
"""Block contact."""
await self.rpc.block_contact(self.account_id, self.contact_id)
async def unblock(self):
"""Unblock contact."""
await self.rpc.unblock_contact(self.account_id, self.contact_id)
async def delete(self):
"""Delete contact."""
await self.rpc.delete_contact(self.account_id, self.contact_id)
async def change_name(self, name: str):
await self.rpc.change_contact_name(self.account_id, self.contact_id, name)
async def get_encryption_info(self) -> str:
return await self.rpc.get_contact_encryption_info(
self.account_id, self.contact_id
)
async def get_dictionary(self):
"""Returns a dictionary with a snapshot of all contact properties."""
return await self.rpc.get_contact(self.account_id, self.contact_id)
async def create_chat(self):
from .chat import Chat
return Chat(
self.rpc,
self.account_id,
await self.rpc.create_chat_by_contact_id(self.account_id, self.contact_id),
)

View File

@@ -0,0 +1,31 @@
from .account import Account
class Deltachat:
"""
Delta Chat account manager.
This is the root of the object oriented API.
"""
def __init__(self, rpc):
self.rpc = rpc
async def add_account(self):
account_id = await self.rpc.add_account()
return Account(self.rpc, account_id)
async def get_all_accounts(self):
account_ids = await self.rpc.get_all_account_ids()
return [Account(self.rpc, account_id) for account_id in account_ids]
async def start_io(self) -> None:
await self.rpc.start_io_for_all_accounts()
async def stop_io(self) -> None:
await self.rpc.stop_io_for_all_accounts()
async def maybe_network(self) -> None:
await self.rpc.maybe_network()
async def get_system_info(self):
return await self.rpc.get_system_info()

View File

@@ -0,0 +1,41 @@
from dataclasses import dataclass
from typing import Optional
from .chat import Chat
from .contact import Contact
class Message:
def __init__(self, rpc, account_id, msg_id):
self.rpc = rpc
self.account_id = account_id
self.msg_id = msg_id
async def send_reaction(self, reactions):
msg_id = await self.rpc.send_reaction(self.account_id, self.msg_id, reactions)
return Message(self.rpc, self.account_id, msg_id)
async def get_snapshot(self):
message_object = await self.rpc.get_message(self.account_id, self.msg_id)
return MessageSnapshot(
message=self,
chat=Chat(self.rpc, self.account_id, message_object["chatId"]),
sender=Contact(self.rpc, self.account_id, message_object["fromId"]),
text=message_object["text"],
error=message_object.get("error"),
is_info=message_object["isInfo"],
)
async def mark_seen(self) -> None:
"""Mark the message as seen."""
await self.rpc.markseen_msgs(self.account_id, [self.msg_id])
@dataclass
class MessageSnapshot:
message: Message
chat: Chat
sender: Contact
text: str
error: Optional[str]
is_info: bool

View File

@@ -0,0 +1,92 @@
import asyncio
import json
import logging
import os
import aiohttp
class JsonRpcError(Exception):
pass
class Rpc:
def __init__(self, process):
self.process = process
self.event_queues = {}
self.id = 0
self.reader_task = asyncio.create_task(self.reader_loop())
# Map from request ID to `asyncio.Future` returning the response.
self.request_events = {}
async def reader_loop(self):
while True:
line = await self.process.stdout.readline()
response = json.loads(line)
if "id" in response:
fut = self.request_events.pop(response["id"])
fut.set_result(response)
elif response["method"] == "event":
# An event notification.
params = response["params"]
account_id = params["contextId"]
if account_id not in self.event_queues:
self.event_queues[account_id] = asyncio.Queue()
await self.event_queues[account_id].put(params["event"])
else:
print(response)
async def wait_for_event(self, account_id):
"""Waits for the next event from the given account and returns it."""
if account_id in self.event_queues:
return await self.event_queues[account_id].get()
def __getattr__(self, attr):
async def method(*args, **kwargs):
self.id += 1
request_id = self.id
params = args
if kwargs:
assert not args
params = kwargs
request = {
"jsonrpc": "2.0",
"method": attr,
"params": params,
"id": self.id,
}
data = (json.dumps(request) + "\n").encode()
self.process.stdin.write(data)
event = asyncio.Event()
loop = asyncio.get_running_loop()
fut = loop.create_future()
self.request_events[request_id] = fut
response = await fut
if "error" in response:
raise JsonRpcError(response["error"])
if "result" in response:
return response["result"]
return method
async def start_rpc_server(*args, **kwargs):
proc = await asyncio.create_subprocess_exec(
"deltachat-rpc-server",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
*args,
**kwargs
)
rpc = Rpc(proc)
return rpc
async def new_online_account():
url = os.getenv("DCC_NEW_TMP_EMAIL")
async with aiohttp.ClientSession() as session:
async with session.post(url) as response:
return json.loads(await response.text())

View File

@@ -0,0 +1,92 @@
import asyncio
import os
import pytest
import pytest_asyncio
import deltachat_rpc_client
from deltachat_rpc_client import Deltachat
@pytest_asyncio.fixture
async def rpc(tmp_path):
return await deltachat_rpc_client.start_rpc_server(
env={**os.environ, "DC_ACCOUNTS_PATH": str(tmp_path / "accounts")}
)
@pytest.mark.asyncio
async def test_system_info(rpc):
system_info = await rpc.get_system_info()
assert "arch" in system_info
assert "deltachat_core_version" in system_info
@pytest.mark.asyncio
async def test_email_address_validity(rpc):
valid_addresses = [
"email@example.com",
"36aa165ae3406424e0c61af17700f397cad3fe8ab83d682d0bddf3338a5dd52e@yggmail@yggmail",
]
invalid_addresses = ["email@", "example.com", "emai221"]
for addr in valid_addresses:
assert await rpc.check_email_validity(addr)
for addr in invalid_addresses:
assert not await rpc.check_email_validity(addr)
@pytest.mark.asyncio
async def test_online_account(rpc):
account_json = await deltachat_rpc_client.new_online_account()
account_id = await rpc.add_account()
await rpc.set_config(account_id, "addr", account_json["email"])
await rpc.set_config(account_id, "mail_pw", account_json["password"])
await rpc.configure(account_id)
while True:
event = await rpc.wait_for_event(account_id)
if event["type"] == "ConfigureProgress":
# Progress 0 indicates error.
assert event["progress"] != 0
if event["progress"] == 1000:
# Success.
break
else:
print(event)
print("Successful configuration")
@pytest.mark.asyncio
async def test_object_account(rpc):
deltachat = Deltachat(rpc)
async def create_configured_account():
account = await deltachat.add_account()
assert not await account.is_configured()
account_json = await deltachat_rpc_client.new_online_account()
await account.set_config("addr", account_json["email"])
await account.set_config("mail_pw", account_json["password"])
await account.configure()
assert await account.is_configured()
return account
alice, bob = await asyncio.gather(
create_configured_account(), create_configured_account()
)
alice_contact_bob = await alice.create_contact(await bob.get_config("addr"), "Bob")
alice_chat_bob = await alice_contact_bob.create_chat()
await alice_chat_bob.send_text("Hello!")
while True:
event = await bob.wait_for_event()
if event["type"] == "IncomingMsg":
chat_id = event["chatId"]
msg_id = event["msgId"]
break
message = await rpc.get_message(bob.account_id, msg_id)
assert message["text"] == "Hello!"

View File

@@ -0,0 +1,20 @@
[tox]
isolated_build = true
envlist =
py3
[testenv]
commands =
pytest {posargs}
setenv =
# Avoid stack overflow when Rust core is built without optimizations.
RUST_MIN_STACK=8388608
PATH = {env:PATH}{:}{toxinidir}/../target/debug
passenv =
DCC_NEW_TMP_EMAIL
deps =
pytest
pytest-async
pytest-asyncio
aiohttp
aiodns