Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JSON-RPC python client #3734

Merged
merged 25 commits into from
Dec 4, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
9b04a04
Add async python client for Delta Chat core JSON-RPC
link2xt Nov 5, 2022
4e2aece
Do not reverse the list of fresh messages
link2xt Nov 30, 2022
db38cc8
Add get_fresh_messages_in_arrival_order() call
link2xt Nov 30, 2022
53d6807
Simplify process_messages() in echo bot
link2xt Nov 30, 2022
240b335
fix bugs in account.secure_join() and chat.get_fresh_message_count()
adbenitez Dec 1, 2022
a4be2cd
remove unused code in rpc.py
adbenitez Dec 1, 2022
a77c46b
fix bug in account.py: arguments declared as optional but not default…
adbenitez Dec 1, 2022
aeb7e3a
fix some linter warnings
adbenitez Dec 1, 2022
1842656
fix bug in chat.get_encryption_info()
adbenitez Dec 1, 2022
46594ec
improve typing hints
adbenitez Dec 1, 2022
ab7732d
fix type hint in rpc.py
adbenitez Dec 1, 2022
09db062
fix bug in Rpc.__getattr__()
adbenitez Dec 1, 2022
ffbfeab
add pytest plugin
adbenitez Dec 1, 2022
e6ff513
add support for PEP 561
adbenitez Dec 1, 2022
d17ac9c
add start_rpc_server() doc string
adbenitez Dec 1, 2022
85b4746
Turn start_rpc_server into a context manager
link2xt Dec 1, 2022
24db29f
Merge remote-tracking branch 'upstream/link2xt/async-jsonrpc-client' …
adbenitez Dec 2, 2022
29a4404
enably type-checking in tests
adbenitez Dec 2, 2022
98b6b5e
Update instructions on using ipython
link2xt Dec 3, 2022
5a30653
Properly terminate Rpc and remove sleep() hack
link2xt Dec 3, 2022
2ccf398
Remove start_rpc_server() and make Rpc a context manager
link2xt Dec 4, 2022
3cdbe21
python: rename Deltachat class into DeltaChat
link2xt Dec 4, 2022
bad5a1d
Ignore .tox everywhere, not only in python/
link2xt Dec 4, 2022
ee19789
Make _rpc private
link2xt Dec 4, 2022
5502bff
Make _args and _kwargs private in Rpc
link2xt Dec 4, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ include
*.db
*.db-blobs

.tox
python/.eggs
python/.tox
*.egg-info
__pycache__
python/src/deltachat/capi*.so
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions deltachat-rpc-client/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# 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`.
adbenitez marked this conversation as resolved.
Show resolved Hide resolved

Additional arguments to `tox` are passed to pytest, e.g. `tox -- -s` does not capture test output.

## Using in REPL
adbenitez marked this conversation as resolved.
Show resolved Hide resolved

Setup a development environment:
```
$ tox --devenv env
$ . env/bin/activate
```

It is recommended to use IPython, because it supports using `await` directly
from the REPL.

```
$ pip install ipython
$ PATH="../target/debug:$PATH" ipython
...
In [1]: from deltachat_rpc_client import *
In [2]: rpc = Rpc()
In [3]: await rpc.start()
In [4]: dc = DeltaChat(rpc)
In [5]: system_info = await dc.get_system_info()
In [6]: system_info["level"]
Out[6]: 'awesome'
In [7]: await rpc.close()
```
54 changes: 54 additions & 0 deletions deltachat-rpc-client/examples/echobot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
import asyncio
import logging
import sys

import deltachat_rpc_client as dc


async def main():
async with dc.Rpc() as rpc:
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():
for message in await account.get_fresh_messages_in_arrival_order():
snapshot = await message.get_snapshot()
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())
29 changes: 29 additions & 0 deletions deltachat-rpc-client/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
[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"
]

[tool.setuptools]
# We declare the package not-zip-safe so that our type hints are also available
# when checking client code that uses our (installed) package.
# Ref:
# https://mypy.readthedocs.io/en/stable/installed_packages.html?highlight=zip#using-installed-packages-with-mypy-pep-561
zip-safe = false

[tool.setuptools.package-data]
deltachat_rpc_client = [
"py.typed"
]

[project.entry-points.pytest11]
"deltachat_rpc_client.pytestplugin" = "deltachat_rpc_client.pytestplugin"
5 changes: 5 additions & 0 deletions deltachat-rpc-client/src/deltachat_rpc_client/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .account import Account
from .contact import Contact
from .deltachat import DeltaChat
from .message import Message
from .rpc import Rpc
79 changes: 79 additions & 0 deletions deltachat-rpc-client/src/deltachat_rpc_client/account.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
from typing import List, Optional

from .chat import Chat
from .contact import Contact
from .message import Message


class Account:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is no way to create a group or get a chat by id here?

def __init__(self, rpc, account_id) -> None:
self._rpc = rpc
self.account_id = account_id

def __repr__(self) -> str:
return f"<Account id={self.account_id}>"

async def wait_for_event(self) -> dict:
"""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) -> dict:
return await self._rpc.get_info(self.account_id)

async def get_file_size(self) -> int:
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] = None) -> None:
"""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) -> None:
"""Configure an account."""
await self._rpc.configure(self.account_id)

async def create_contact(self, address: str, name: Optional[str] = None) -> 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, qrdata: str) -> Chat:
chat_id = await self._rpc.secure_join(self.account_id, qrdata)
return Chat(self._rpc, self.account_id, chat_id)

async def get_fresh_messages(self) -> List[Message]:
"""Return the list of fresh messages, newest messages first.

This call is intended for displaying notifications.
If you are writing a bot, use get_fresh_messages_in_arrival_order instead,
to process oldest messages first.
"""
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]

async def get_fresh_messages_in_arrival_order(self) -> List[Message]:
"""Return fresh messages list sorted in the order of their arrival, with ascending IDs."""
fresh_msg_ids = sorted(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]
41 changes: 41 additions & 0 deletions deltachat-rpc-client/src/deltachat_rpc_client/chat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from typing import TYPE_CHECKING

from .rpc import Rpc

if TYPE_CHECKING:
from .message import Message


class Chat:
def __init__(self, rpc: Rpc, account_id: int, chat_id: int) -> None:
self._rpc = rpc
self.account_id = account_id
self.chat_id = chat_id

async def block(self) -> None:
"""Block the chat."""
await self._rpc.block_chat(self.account_id, self.chat_id)

async def accept(self) -> None:
"""Accept the contact request."""
await self._rpc.accept_chat(self.account_id, self.chat_id)

async def delete(self) -> None:
await self._rpc.delete_chat(self.account_id, self.chat_id)

async def get_encryption_info(self) -> str:
return await self._rpc.get_chat_encryption_info(self.account_id, self.chat_id)

async def send_text(self, text: str) -> "Message":
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) -> None:
await self._rpc.leave_group(self.account_id, self.chat_id)

async def get_fresh_message_count(self) -> int:
return await self._rpc.get_fresh_msg_cnt(self.account_id, self.chat_id)
52 changes: 52 additions & 0 deletions deltachat-rpc-client/src/deltachat_rpc_client/contact.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from typing import TYPE_CHECKING

from .rpc import Rpc

if TYPE_CHECKING:
from .chat import Chat


class Contact:
"""
Contact API.

Essentially a wrapper for RPC, account ID and a contact ID.
"""

def __init__(self, rpc: Rpc, account_id: int, contact_id: int) -> None:
self._rpc = rpc
self.account_id = account_id
self.contact_id = contact_id

async def block(self) -> None:
"""Block contact."""
await self._rpc.block_contact(self.account_id, self.contact_id)

async def unblock(self) -> None:
"""Unblock contact."""
await self._rpc.unblock_contact(self.account_id, self.contact_id)

async def delete(self) -> None:
"""Delete contact."""
await self._rpc.delete_contact(self.account_id, self.contact_id)

async def change_name(self, name: str) -> None:
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) -> dict:
"""Return 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) -> "Chat":
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),
)
34 changes: 34 additions & 0 deletions deltachat-rpc-client/src/deltachat_rpc_client/deltachat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from typing import List

from .account import Account
from .rpc import Rpc


class DeltaChat:
"""
Delta Chat account manager.
This is the root of the object oriented API.
"""

def __init__(self, rpc: Rpc) -> None:
self.rpc = rpc

async def add_account(self) -> Account:
account_id = await self.rpc.add_account()
return Account(self.rpc, account_id)

async def get_all_accounts(self) -> List[Account]:
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) -> dict:
return await self.rpc.get_system_info()
Loading