docs: add missing documentation to deltachat-rpc-client

This commit is contained in:
link2xt
2025-05-09 21:02:42 +00:00
parent a981573e48
commit c1471bdbd9
12 changed files with 160 additions and 77 deletions

View File

@@ -1,4 +1,4 @@
"""Delta Chat JSON-RPC high-level API""" """Delta Chat JSON-RPC high-level API."""
from ._utils import AttrDict, run_bot_cli, run_client_cli from ._utils import AttrDict, run_bot_cli, run_client_cli
from .account import Account from .account import Account

View File

@@ -115,7 +115,7 @@ def _run_cli(
def extract_addr(text: str) -> str: def extract_addr(text: str) -> str:
"""extract email address from the given text.""" """Extract email address from the given text."""
match = re.match(r".*\((.+@.+)\)", text) match = re.match(r".*\((.+@.+)\)", text)
if match: if match:
text = match.group(1) text = match.group(1)
@@ -124,7 +124,7 @@ def extract_addr(text: str) -> str:
def parse_system_image_changed(text: str) -> Optional[Tuple[str, bool]]: def parse_system_image_changed(text: str) -> Optional[Tuple[str, bool]]:
"""return image changed/deleted info from parsing the given system message text.""" """Return image changed/deleted info from parsing the given system message text."""
text = text.lower() text = text.lower()
match = re.match(r"group image (changed|deleted) by (.+).", text) match = re.match(r"group image (changed|deleted) by (.+).", text)
if match: if match:
@@ -143,7 +143,7 @@ def parse_system_title_changed(text: str) -> Optional[Tuple[str, str]]:
def parse_system_add_remove(text: str) -> Optional[Tuple[str, str, str]]: def parse_system_add_remove(text: str) -> Optional[Tuple[str, str, str]]:
"""return add/remove info from parsing the given system message text. """Return add/remove info from parsing the given system message text.
returns a (action, affected, actor) tuple. returns a (action, affected, actor) tuple.
""" """

View File

@@ -1,3 +1,5 @@
"""Account module."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
@@ -34,7 +36,10 @@ class Account:
return next_event return next_event
def clear_all_events(self): def clear_all_events(self):
"""Removes all queued-up events for a given account. Useful for tests.""" """Remove all queued-up events for a given account.
Useful for tests.
"""
self._rpc.clear_all_events(self.id) self._rpc.clear_all_events(self.id)
def remove(self) -> None: def remove(self) -> None:
@@ -43,7 +48,9 @@ class Account:
def clone(self) -> "Account": def clone(self) -> "Account":
"""Clone given account. """Clone given account.
This uses backup-transfer via iroh, i.e. the 'Add second device' feature."""
This uses backup-transfer via iroh, i.e. the 'Add second device' feature.
"""
future = self._rpc.provide_backup.future(self.id) future = self._rpc.provide_backup.future(self.id)
qr = self._rpc.get_backup_qr(self.id) qr = self._rpc.get_backup_qr(self.id)
new_account = self.manager.add_account() new_account = self.manager.add_account()
@@ -80,7 +87,7 @@ class Account:
return self._rpc.get_config(self.id, key) return self._rpc.get_config(self.id, key)
def update_config(self, **kwargs) -> None: def update_config(self, **kwargs) -> None:
"""update config values.""" """Update config values."""
for key, value in kwargs.items(): for key, value in kwargs.items():
self.set_config(key, value) self.set_config(key, value)
@@ -99,10 +106,12 @@ class Account:
"""Parse QR code contents. """Parse QR code contents.
This function takes the raw text scanned This function takes the raw text scanned
and checks what can be done with it.""" and checks what can be done with it.
"""
return self._rpc.check_qr(self.id, qr) return self._rpc.check_qr(self.id, qr)
def set_config_from_qr(self, qr: str): def set_config_from_qr(self, qr: str):
"""Set configuration values from a QR code."""
self._rpc.set_config_from_qr(self.id, qr) self._rpc.set_config_from_qr(self.id, qr)
@futuremethod @futuremethod
@@ -117,7 +126,7 @@ class Account:
@futuremethod @futuremethod
def list_transports(self): def list_transports(self):
"""Returns the list of all email accounts that are used as a transport in the current profile.""" """Return the list of all email accounts that are used as a transport in the current profile."""
transports = yield self._rpc.list_transports.future(self.id) transports = yield self._rpc.list_transports.future(self.id)
return transports return transports
@@ -158,7 +167,8 @@ class Account:
def import_vcard(self, vcard: str) -> list[Contact]: def import_vcard(self, vcard: str) -> list[Contact]:
"""Import vCard. """Import vCard.
Return created or modified contacts in the order they appear in vCard.""" Return created or modified contacts in the order they appear in vCard.
"""
contact_ids = self._rpc.import_vcard_contents(self.id, vcard) contact_ids = self._rpc.import_vcard_contents(self.id, vcard)
return [Contact(self, contact_id) for contact_id in contact_ids] return [Contact(self, contact_id) for contact_id in contact_ids]
@@ -227,12 +237,12 @@ class Account:
@property @property
def self_contact(self) -> Contact: def self_contact(self) -> Contact:
"""This account's identity as a Contact.""" """Account's identity as a Contact."""
return Contact(self, SpecialContactId.SELF) return Contact(self, SpecialContactId.SELF)
@property @property
def device_contact(self) -> Chat: def device_contact(self) -> Chat:
"""This account's device contact.""" """Account's device contact."""
return Contact(self, SpecialContactId.DEVICE) return Contact(self, SpecialContactId.DEVICE)
def get_chatlist( def get_chatlist(
@@ -290,8 +300,7 @@ class Account:
return Chat(self, chat_id) return Chat(self, chat_id)
def secure_join(self, qrdata: str) -> Chat: def secure_join(self, qrdata: str) -> Chat:
"""Continue a Setup-Contact or Verified-Group-Invite protocol started on """Continue a Setup-Contact or Verified-Group-Invite protocol started on another device.
another device.
The function returns immediately and the handshake runs in background, sending The function returns immediately and the handshake runs in background, sending
and receiving several messages. and receiving several messages.
@@ -361,22 +370,26 @@ class Account:
def wait_for_incoming_msg(self): def wait_for_incoming_msg(self):
"""Wait for incoming message and return it. """Wait for incoming message and return it.
Consumes all events before the next incoming message event.""" Consumes all events before the next incoming message event.
"""
return self.get_message_by_id(self.wait_for_incoming_msg_event().msg_id) return self.get_message_by_id(self.wait_for_incoming_msg_event().msg_id)
def wait_for_securejoin_inviter_success(self): def wait_for_securejoin_inviter_success(self):
"""Wait until SecureJoin process finishes successfully on the inviter side."""
while True: while True:
event = self.wait_for_event() event = self.wait_for_event()
if event["kind"] == "SecurejoinInviterProgress" and event["progress"] == 1000: if event["kind"] == "SecurejoinInviterProgress" and event["progress"] == 1000:
break break
def wait_for_securejoin_joiner_success(self): def wait_for_securejoin_joiner_success(self):
"""Wait until SecureJoin process finishes successfully on the joiner side."""
while True: while True:
event = self.wait_for_event() event = self.wait_for_event()
if event["kind"] == "SecurejoinJoinerProgress" and event["progress"] == 1000: if event["kind"] == "SecurejoinJoinerProgress" and event["progress"] == 1000:
break break
def wait_for_reactions_changed(self): def wait_for_reactions_changed(self):
"""Wait for reaction change event."""
return self.wait_for_event(EventType.REACTIONS_CHANGED) return self.wait_for_event(EventType.REACTIONS_CHANGED)
def get_fresh_messages_in_arrival_order(self) -> list[Message]: def get_fresh_messages_in_arrival_order(self) -> list[Message]:

View File

@@ -1,3 +1,5 @@
"""Chat module."""
from __future__ import annotations from __future__ import annotations
import calendar import calendar
@@ -89,7 +91,8 @@ class Chat:
def set_ephemeral_timer(self, timer: int) -> None: def set_ephemeral_timer(self, timer: int) -> None:
"""Set ephemeral timer of this chat in seconds. """Set ephemeral timer of this chat in seconds.
0 means the timer is disabled, use 1 for immediate deletion.""" 0 means the timer is disabled, use 1 for immediate deletion.
"""
self._rpc.set_chat_ephemeral_timer(self.account.id, self.id, timer) self._rpc.set_chat_ephemeral_timer(self.account.id, self.id, timer)
def get_encryption_info(self) -> str: def get_encryption_info(self) -> str:
@@ -199,12 +202,12 @@ class Chat:
return snapshot return snapshot
def get_messages(self, info_only: bool = False, add_daymarker: bool = False) -> list[Message]: def get_messages(self, info_only: bool = False, add_daymarker: bool = False) -> list[Message]:
"""get the list of messages in this chat.""" """Get the list of messages in this chat."""
msgs = self._rpc.get_message_ids(self.account.id, self.id, info_only, add_daymarker) msgs = self._rpc.get_message_ids(self.account.id, self.id, info_only, add_daymarker)
return [Message(self.account, msg_id) for msg_id in msgs] return [Message(self.account, msg_id) for msg_id in msgs]
def get_fresh_message_count(self) -> int: def get_fresh_message_count(self) -> int:
"""Get number of fresh messages in this chat""" """Get number of fresh messages in this chat."""
return self._rpc.get_fresh_msg_cnt(self.account.id, self.id) return self._rpc.get_fresh_msg_cnt(self.account.id, self.id)
def mark_noticed(self) -> None: def mark_noticed(self) -> None:

View File

@@ -48,6 +48,7 @@ class Client:
self.add_hooks(hooks or []) self.add_hooks(hooks or [])
def add_hooks(self, hooks: Iterable[tuple[Callable, Union[type, EventFilter]]]) -> None: def add_hooks(self, hooks: Iterable[tuple[Callable, Union[type, EventFilter]]]) -> None:
"""Register multiple hooks."""
for hook, event in hooks: for hook, event in hooks:
self.add_hook(hook, event) self.add_hook(hook, event)
@@ -77,9 +78,11 @@ class Client:
self._hooks.get(type(event), set()).remove((hook, event)) self._hooks.get(type(event), set()).remove((hook, event))
def is_configured(self) -> bool: def is_configured(self) -> bool:
"""Return True if the client is configured."""
return self.account.is_configured() return self.account.is_configured()
def configure(self, email: str, password: str, **kwargs) -> None: def configure(self, email: str, password: str, **kwargs) -> None:
"""Configure the client."""
self.account.set_config("addr", email) self.account.set_config("addr", email)
self.account.set_config("mail_pw", password) self.account.set_config("mail_pw", password)
for key, value in kwargs.items(): for key, value in kwargs.items():
@@ -198,5 +201,6 @@ class Bot(Client):
"""Simple bot implementation that listens to events of a single account.""" """Simple bot implementation that listens to events of a single account."""
def configure(self, email: str, password: str, **kwargs) -> None: def configure(self, email: str, password: str, **kwargs) -> None:
"""Configure the bot."""
kwargs.setdefault("bot", "1") kwargs.setdefault("bot", "1")
super().configure(email, password, **kwargs) super().configure(email, password, **kwargs)

View File

@@ -1,14 +1,20 @@
"""Constants module."""
from enum import Enum, IntEnum from enum import Enum, IntEnum
COMMAND_PREFIX = "/" COMMAND_PREFIX = "/"
class ContactFlag(IntEnum): class ContactFlag(IntEnum):
"""Bit flags for get_contacts() method."""
VERIFIED_ONLY = 0x01 VERIFIED_ONLY = 0x01
ADD_SELF = 0x02 ADD_SELF = 0x02
class ChatlistFlag(IntEnum): class ChatlistFlag(IntEnum):
"""Bit flags for get_chatlist() method."""
ARCHIVED_ONLY = 0x01 ARCHIVED_ONLY = 0x01
NO_SPECIALS = 0x02 NO_SPECIALS = 0x02
ADD_ALLDONE_HINT = 0x04 ADD_ALLDONE_HINT = 0x04
@@ -16,6 +22,8 @@ class ChatlistFlag(IntEnum):
class SpecialContactId(IntEnum): class SpecialContactId(IntEnum):
"""Special contact IDs."""
SELF = 1 SELF = 1
INFO = 2 # centered messages as "member added", used in all chats INFO = 2 # centered messages as "member added", used in all chats
DEVICE = 5 # messages "update info" in the device-chat DEVICE = 5 # messages "update info" in the device-chat
@@ -23,7 +31,7 @@ class SpecialContactId(IntEnum):
class EventType(str, Enum): class EventType(str, Enum):
"""Core event types""" """Core event types."""
INFO = "Info" INFO = "Info"
SMTP_CONNECTED = "SmtpConnected" SMTP_CONNECTED = "SmtpConnected"
@@ -71,7 +79,7 @@ class EventType(str, Enum):
class ChatId(IntEnum): class ChatId(IntEnum):
"""Special chat ids""" """Special chat IDs."""
TRASH = 3 TRASH = 3
ARCHIVED_LINK = 6 ARCHIVED_LINK = 6
@@ -80,7 +88,7 @@ class ChatId(IntEnum):
class ChatType(IntEnum): class ChatType(IntEnum):
"""Chat types""" """Chat type."""
UNDEFINED = 0 UNDEFINED = 0
SINGLE = 100 SINGLE = 100
@@ -90,7 +98,7 @@ class ChatType(IntEnum):
class ChatVisibility(str, Enum): class ChatVisibility(str, Enum):
"""Chat visibility types""" """Chat visibility types."""
NORMAL = "Normal" NORMAL = "Normal"
ARCHIVED = "Archived" ARCHIVED = "Archived"
@@ -98,7 +106,7 @@ class ChatVisibility(str, Enum):
class DownloadState(str, Enum): class DownloadState(str, Enum):
"""Message download state""" """Message download state."""
DONE = "Done" DONE = "Done"
AVAILABLE = "Available" AVAILABLE = "Available"
@@ -159,14 +167,14 @@ class MessageState(IntEnum):
class MessageId(IntEnum): class MessageId(IntEnum):
"""Special message ids""" """Special message IDs."""
DAYMARKER = 9 DAYMARKER = 9
LAST_SPECIAL = 9 LAST_SPECIAL = 9
class CertificateChecks(IntEnum): class CertificateChecks(IntEnum):
"""Certificate checks mode""" """Certificate checks mode."""
AUTOMATIC = 0 AUTOMATIC = 0
STRICT = 1 STRICT = 1
@@ -174,7 +182,7 @@ class CertificateChecks(IntEnum):
class Connectivity(IntEnum): class Connectivity(IntEnum):
"""Connectivity states""" """Connectivity states."""
NOT_CONNECTED = 1000 NOT_CONNECTED = 1000
CONNECTING = 2000 CONNECTING = 2000
@@ -183,7 +191,7 @@ class Connectivity(IntEnum):
class KeyGenType(IntEnum): class KeyGenType(IntEnum):
"""Type of the key to generate""" """Type of the key to generate."""
DEFAULT = 0 DEFAULT = 0
RSA2048 = 1 RSA2048 = 1
@@ -193,21 +201,21 @@ class KeyGenType(IntEnum):
# "Lp" means "login parameters" # "Lp" means "login parameters"
class LpAuthFlag(IntEnum): class LpAuthFlag(IntEnum):
"""Authorization flags""" """Authorization flags."""
OAUTH2 = 0x2 OAUTH2 = 0x2
NORMAL = 0x4 NORMAL = 0x4
class MediaQuality(IntEnum): class MediaQuality(IntEnum):
"""Media quality setting""" """Media quality setting."""
BALANCED = 0 BALANCED = 0
WORSE = 1 WORSE = 1
class ProviderStatus(IntEnum): class ProviderStatus(IntEnum):
"""Provider status according to manual testing""" """Provider status according to manual testing."""
OK = 1 OK = 1
PREPARATION = 2 PREPARATION = 2
@@ -215,7 +223,7 @@ class ProviderStatus(IntEnum):
class PushNotifyState(IntEnum): class PushNotifyState(IntEnum):
"""Push notifications state""" """Push notifications state."""
NOT_CONNECTED = 0 NOT_CONNECTED = 0
HEARTBEAT = 1 HEARTBEAT = 1
@@ -223,7 +231,7 @@ class PushNotifyState(IntEnum):
class ShowEmails(IntEnum): class ShowEmails(IntEnum):
"""Show emails mode""" """Show emails mode."""
OFF = 0 OFF = 0
ACCEPTED_CONTACTS = 1 ACCEPTED_CONTACTS = 1
@@ -231,7 +239,7 @@ class ShowEmails(IntEnum):
class SocketSecurity(IntEnum): class SocketSecurity(IntEnum):
"""Socket security""" """Socket security."""
AUTOMATIC = 0 AUTOMATIC = 0
SSL = 1 SSL = 1
@@ -240,7 +248,7 @@ class SocketSecurity(IntEnum):
class VideochatType(IntEnum): class VideochatType(IntEnum):
"""Video chat URL type""" """Video chat URL type."""
UNKNOWN = 0 UNKNOWN = 0
BASICWEBRTC = 1 BASICWEBRTC = 1

View File

@@ -1,3 +1,5 @@
"""Contact module."""
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -11,8 +13,7 @@ if TYPE_CHECKING:
@dataclass @dataclass
class Contact: class Contact:
""" """Contact API.
Contact API.
Essentially a wrapper for RPC, account ID and a contact ID. Essentially a wrapper for RPC, account ID and a contact ID.
""" """
@@ -45,8 +46,9 @@ class Contact:
self._rpc.change_contact_name(self.account.id, self.id, name) self._rpc.change_contact_name(self.account.id, self.id, name)
def get_encryption_info(self) -> str: def get_encryption_info(self) -> str:
"""Get a multi-line encryption info, containing your fingerprint and """Get a multi-line encryption info.
the fingerprint of the contact.
Encryption info contains your fingerprint and the fingerprint of the contact.
""" """
return self._rpc.get_contact_encryption_info(self.account.id, self.id) return self._rpc.get_contact_encryption_info(self.account.id, self.id)
@@ -66,4 +68,5 @@ class Contact:
) )
def make_vcard(self) -> str: def make_vcard(self) -> str:
"""Make a vCard for the contact."""
return self.account.make_vcard([self]) return self.account.make_vcard([self])

View File

@@ -1,3 +1,5 @@
"""Account manager module."""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -10,12 +12,13 @@ if TYPE_CHECKING:
class DeltaChat: class DeltaChat:
""" """Delta Chat accounts manager.
Delta Chat accounts manager.
This is the root of the object oriented API. This is the root of the object oriented API.
""" """
def __init__(self, rpc: "Rpc") -> None: def __init__(self, rpc: "Rpc") -> None:
"""Initialize account manager."""
self.rpc = rpc self.rpc = rpc
def add_account(self) -> Account: def add_account(self) -> Account:
@@ -37,9 +40,7 @@ class DeltaChat:
self.rpc.stop_io_for_all_accounts() self.rpc.stop_io_for_all_accounts()
def maybe_network(self) -> None: def maybe_network(self) -> None:
"""Indicate that the network likely has come back or just that the network """Indicate that the network conditions might have changed."""
conditions might have changed.
"""
self.rpc.maybe_network() self.rpc.maybe_network()
def get_system_info(self) -> AttrDict: def get_system_info(self) -> AttrDict:

View File

@@ -36,7 +36,7 @@ class EventFilter(ABC):
@abstractmethod @abstractmethod
def __hash__(self) -> int: def __hash__(self) -> int:
"""Object's unique hash""" """Object's unique hash."""
@abstractmethod @abstractmethod
def __eq__(self, other) -> bool: def __eq__(self, other) -> bool:
@@ -52,9 +52,7 @@ class EventFilter(ABC):
@abstractmethod @abstractmethod
def filter(self, event): def filter(self, event):
"""Return True-like value if the event passed the filter and should be """Return True-like value if the event passed the filter."""
used, or False-like value otherwise.
"""
class RawEvent(EventFilter): class RawEvent(EventFilter):
@@ -82,31 +80,17 @@ class RawEvent(EventFilter):
return False return False
def filter(self, event: "AttrDict") -> bool: def filter(self, event: "AttrDict") -> bool:
"""Filter an event.
Return true if the event should be processed.
"""
if self.types and event.kind not in self.types: if self.types and event.kind not in self.types:
return False return False
return self._call_func(event) return self._call_func(event)
class NewMessage(EventFilter): class NewMessage(EventFilter):
"""Matches whenever a new message arrives. """Matches whenever a new message arrives."""
Warning: registering a handler for this event will cause the messages
to be marked as read. Its usage is mainly intended for bots.
:param pattern: if set, this Pattern will be used to filter the message by its text
content.
:param command: If set, only match messages with the given command (ex. /help).
Setting this property implies `is_info==False`.
:param is_bot: If set to True only match messages sent by bots, if set to None
match messages from bots and users. If omitted or set to False
only messages from users will be matched.
:param is_info: If set to True only match info/system messages, if set to False
only match messages that are not info/system messages. If omitted
info/system messages as well as normal messages will be matched.
:param func: A Callable function that should accept the event as input
parameter, and return a bool value indicating whether the event
should be dispatched or not.
"""
def __init__( def __init__(
self, self,
@@ -121,6 +105,25 @@ class NewMessage(EventFilter):
is_info: Optional[bool] = None, is_info: Optional[bool] = None,
func: Optional[Callable[["AttrDict"], bool]] = None, func: Optional[Callable[["AttrDict"], bool]] = None,
) -> None: ) -> None:
"""Initialize a new message filter.
Warning: registering a handler for this event will cause the messages
to be marked as read. Its usage is mainly intended for bots.
:param pattern: if set, this Pattern will be used to filter the message by its text
content.
:param command: If set, only match messages with the given command (ex. /help).
Setting this property implies `is_info==False`.
:param is_bot: If set to True only match messages sent by bots, if set to None
match messages from bots and users. If omitted or set to False
only messages from users will be matched.
:param is_info: If set to True only match info/system messages, if set to False
only match messages that are not info/system messages. If omitted
info/system messages as well as normal messages will be matched.
:param func: A Callable function that should accept the event as input
parameter, and return a bool value indicating whether the event
should be dispatched or not.
"""
super().__init__(func=func) super().__init__(func=func)
self.is_bot = is_bot self.is_bot = is_bot
self.is_info = is_info self.is_info = is_info
@@ -159,6 +162,7 @@ class NewMessage(EventFilter):
return False return False
def filter(self, event: "AttrDict") -> bool: def filter(self, event: "AttrDict") -> bool:
"""Return true if if the event is a new message event."""
if self.is_bot is not None and self.is_bot != event.message_snapshot.is_bot: if self.is_bot is not None and self.is_bot != event.message_snapshot.is_bot:
return False return False
if self.is_info is not None and self.is_info != event.message_snapshot.is_info: if self.is_info is not None and self.is_info != event.message_snapshot.is_info:
@@ -199,6 +203,7 @@ class MemberListChanged(EventFilter):
return False return False
def filter(self, event: "AttrDict") -> bool: def filter(self, event: "AttrDict") -> bool:
"""Return true if if the event is a member addition event."""
if self.added is not None and self.added != event.member_added: if self.added is not None and self.added != event.member_added:
return False return False
return self._call_func(event) return self._call_func(event)
@@ -231,6 +236,7 @@ class GroupImageChanged(EventFilter):
return False return False
def filter(self, event: "AttrDict") -> bool: def filter(self, event: "AttrDict") -> bool:
"""Return True if event is matched."""
if self.deleted is not None and self.deleted != event.image_deleted: if self.deleted is not None and self.deleted != event.image_deleted:
return False return False
return self._call_func(event) return self._call_func(event)
@@ -256,13 +262,12 @@ class GroupNameChanged(EventFilter):
return False return False
def filter(self, event: "AttrDict") -> bool: def filter(self, event: "AttrDict") -> bool:
"""Return True if event is matched."""
return self._call_func(event) return self._call_func(event)
class HookCollection: class HookCollection:
""" """Helper class to collect event hooks that can later be added to a Delta Chat client."""
Helper class to collect event hooks that can later be added to a Delta Chat client.
"""
def __init__(self) -> None: def __init__(self) -> None:
self._hooks: set[tuple[Callable, Union[type, EventFilter]]] = set() self._hooks: set[tuple[Callable, Union[type, EventFilter]]] = set()

View File

@@ -1,3 +1,5 @@
"""Message module."""
import json import json
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional, Union from typing import TYPE_CHECKING, Optional, Union
@@ -45,6 +47,7 @@ class Message:
return None return None
def get_sender_contact(self) -> Contact: def get_sender_contact(self) -> Contact:
"""Return sender contact."""
from_id = self.get_snapshot().from_id from_id = self.get_snapshot().from_id
return self.account.get_contact_by_id(from_id) return self.account.get_contact_by_id(from_id)
@@ -53,6 +56,11 @@ class Message:
self._rpc.markseen_msgs(self.account.id, [self.id]) self._rpc.markseen_msgs(self.account.id, [self.id])
def continue_autocrypt_key_transfer(self, setup_code: str) -> None: def continue_autocrypt_key_transfer(self, setup_code: str) -> None:
"""Continue the Autocrypt Setup Message key transfer.
This function can be called on received Autocrypt Setup Message
to import the key encrypted with the provided setup code.
"""
self._rpc.continue_autocrypt_key_transfer(self.account.id, self.id, setup_code) self._rpc.continue_autocrypt_key_transfer(self.account.id, self.id, setup_code)
def send_webxdc_status_update(self, update: Union[dict, str], description: str) -> None: def send_webxdc_status_update(self, update: Union[dict, str], description: str) -> None:
@@ -62,6 +70,7 @@ class Message:
self._rpc.send_webxdc_status_update(self.account.id, self.id, update, description) self._rpc.send_webxdc_status_update(self.account.id, self.id, update, description)
def get_webxdc_status_updates(self, last_known_serial: int = 0) -> list: def get_webxdc_status_updates(self, last_known_serial: int = 0) -> list:
"""Return a list of Webxdc status updates for Webxdc instance message."""
return json.loads(self._rpc.get_webxdc_status_updates(self.account.id, self.id, last_known_serial)) return json.loads(self._rpc.get_webxdc_status_updates(self.account.id, self.id, last_known_serial))
def get_info(self) -> str: def get_info(self) -> str:
@@ -69,6 +78,7 @@ class Message:
return self._rpc.get_message_info(self.account.id, self.id) return self._rpc.get_message_info(self.account.id, self.id)
def get_webxdc_info(self) -> dict: def get_webxdc_info(self) -> dict:
"""Get info from a Webxdc message in JSON format."""
return self._rpc.get_webxdc_info(self.account.id, self.id) return self._rpc.get_webxdc_info(self.account.id, self.id)
def wait_until_delivered(self) -> None: def wait_until_delivered(self) -> None:
@@ -80,8 +90,10 @@ class Message:
@futuremethod @futuremethod
def send_webxdc_realtime_advertisement(self): def send_webxdc_realtime_advertisement(self):
"""Send an advertisement to join the realtime channel."""
yield self._rpc.send_webxdc_realtime_advertisement.future(self.account.id, self.id) yield self._rpc.send_webxdc_realtime_advertisement.future(self.account.id, self.id)
@futuremethod @futuremethod
def send_webxdc_realtime_data(self, data) -> None: def send_webxdc_realtime_data(self, data) -> None:
"""Send data to the realtime channel."""
yield self._rpc.send_webxdc_realtime_data.future(self.account.id, self.id, list(data)) yield self._rpc.send_webxdc_realtime_data.future(self.account.id, self.id, list(data))

View File

@@ -1,3 +1,5 @@
"""Pytest plugin module."""
from __future__ import annotations from __future__ import annotations
import os import os
@@ -13,24 +15,30 @@ from .rpc import Rpc
class ACFactory: class ACFactory:
"""Test account factory."""
def __init__(self, deltachat: DeltaChat) -> None: def __init__(self, deltachat: DeltaChat) -> None:
self.deltachat = deltachat self.deltachat = deltachat
def get_unconfigured_account(self) -> Account: def get_unconfigured_account(self) -> Account:
"""Create a new unconfigured account."""
account = self.deltachat.add_account() account = self.deltachat.add_account()
account.set_config("verified_one_on_one_chats", "1") account.set_config("verified_one_on_one_chats", "1")
return account return account
def get_unconfigured_bot(self) -> Bot: def get_unconfigured_bot(self) -> Bot:
"""Create a new unconfigured bot."""
return Bot(self.get_unconfigured_account()) return Bot(self.get_unconfigured_account())
def get_credentials(self) -> (str, str): def get_credentials(self) -> (str, str):
"""Generate new credentials for chatmail account."""
domain = os.getenv("CHATMAIL_DOMAIN") domain = os.getenv("CHATMAIL_DOMAIN")
username = "ci-" + "".join(random.choice("2345789acdefghjkmnpqrstuvwxyz") for i in range(6)) username = "ci-" + "".join(random.choice("2345789acdefghjkmnpqrstuvwxyz") for i in range(6))
return f"{username}@{domain}", f"{username}${username}" return f"{username}@{domain}", f"{username}${username}"
@futuremethod @futuremethod
def new_configured_account(self): def new_configured_account(self):
"""Create a new configured account."""
addr, password = self.get_credentials() addr, password = self.get_credentials()
account = self.get_unconfigured_account() account = self.get_unconfigured_account()
params = {"addr": addr, "password": password} params = {"addr": addr, "password": password}
@@ -40,6 +48,7 @@ class ACFactory:
return account return account
def new_configured_bot(self) -> Bot: def new_configured_bot(self) -> Bot:
"""Create a new configured bot."""
addr, password = self.get_credentials() addr, password = self.get_credentials()
bot = self.get_unconfigured_bot() bot = self.get_unconfigured_bot()
bot.configure(addr, password) bot.configure(addr, password)
@@ -47,11 +56,13 @@ class ACFactory:
@futuremethod @futuremethod
def get_online_account(self): def get_online_account(self):
"""Create a new account and start I/O."""
account = yield self.new_configured_account.future() account = yield self.new_configured_account.future()
account.bring_online() account.bring_online()
return account return account
def get_online_accounts(self, num: int) -> list[Account]: def get_online_accounts(self, num: int) -> list[Account]:
"""Create multiple online accounts."""
futures = [self.get_online_account.future() for _ in range(num)] futures = [self.get_online_account.future() for _ in range(num)]
return [f() for f in futures] return [f() for f in futures]
@@ -66,6 +77,10 @@ class ACFactory:
return ac_clone return ac_clone
def get_accepted_chat(self, ac1: Account, ac2: Account) -> Chat: def get_accepted_chat(self, ac1: Account, ac2: Account) -> Chat:
"""Create a new 1:1 chat between ac1 and ac2 accepted on both sides.
Returned chat is a chat with ac2 from ac1 point of view.
"""
ac2.create_chat(ac1) ac2.create_chat(ac1)
return ac1.create_chat(ac2) return ac1.create_chat(ac2)
@@ -77,6 +92,7 @@ class ACFactory:
file: Optional[str] = None, file: Optional[str] = None,
group: Optional[str] = None, group: Optional[str] = None,
) -> Message: ) -> Message:
"""Send a message."""
if not from_account: if not from_account:
from_account = (self.get_online_accounts(1))[0] from_account = (self.get_online_accounts(1))[0]
to_contact = from_account.create_contact(to_account) to_contact = from_account.create_contact(to_account)
@@ -95,6 +111,7 @@ class ACFactory:
file: Optional[str] = None, file: Optional[str] = None,
group: Optional[str] = None, group: Optional[str] = None,
) -> AttrDict: ) -> AttrDict:
"""Send a message and wait until recipient processes it."""
self.send_message( self.send_message(
to_account=to_client.account, to_account=to_client.account,
from_account=from_account, from_account=from_account,
@@ -108,6 +125,7 @@ class ACFactory:
@pytest.fixture @pytest.fixture
def rpc(tmp_path) -> AsyncGenerator: def rpc(tmp_path) -> AsyncGenerator:
"""RPC client fixture."""
rpc_server = Rpc(accounts_dir=str(tmp_path / "accounts")) rpc_server = Rpc(accounts_dir=str(tmp_path / "accounts"))
with rpc_server: with rpc_server:
yield rpc_server yield rpc_server
@@ -115,6 +133,7 @@ def rpc(tmp_path) -> AsyncGenerator:
@pytest.fixture @pytest.fixture
def acfactory(rpc) -> AsyncGenerator: def acfactory(rpc) -> AsyncGenerator:
"""Return account factory fixture."""
return ACFactory(DeltaChat(rpc)) return ACFactory(DeltaChat(rpc))
@@ -132,7 +151,7 @@ def data():
raise Exception("Data path cannot be found") raise Exception("Data path cannot be found")
def get_path(self, bn): def get_path(self, bn):
"""return path of file or None if it doesn't exist.""" """Return path of file or None if it doesn't exist."""
fn = os.path.join(self.path, *bn.split("/")) fn = os.path.join(self.path, *bn.split("/"))
assert os.path.exists(fn) assert os.path.exists(fn)
return fn return fn

View File

@@ -1,3 +1,5 @@
"""JSON-RPC client module."""
from __future__ import annotations from __future__ import annotations
import itertools import itertools
@@ -12,16 +14,19 @@ from typing import Any, Iterator, Optional
class JsonRpcError(Exception): class JsonRpcError(Exception):
pass """JSON-RPC error."""
class RpcFuture: class RpcFuture:
"""RPC future waiting for RPC call result."""
def __init__(self, rpc: "Rpc", request_id: int, event: Event): def __init__(self, rpc: "Rpc", request_id: int, event: Event):
self.rpc = rpc self.rpc = rpc
self.request_id = request_id self.request_id = request_id
self.event = event self.event = event
def __call__(self): def __call__(self):
"""Wait for the future to return the result."""
self.event.wait() self.event.wait()
response = self.rpc.request_results.pop(self.request_id) response = self.rpc.request_results.pop(self.request_id)
if "error" in response: if "error" in response:
@@ -32,17 +37,19 @@ class RpcFuture:
class RpcMethod: class RpcMethod:
"""RPC method."""
def __init__(self, rpc: "Rpc", name: str): def __init__(self, rpc: "Rpc", name: str):
self.rpc = rpc self.rpc = rpc
self.name = name self.name = name
def __call__(self, *args) -> Any: def __call__(self, *args) -> Any:
"""Synchronously calls JSON-RPC method.""" """Call JSON-RPC method synchronously."""
future = self.future(*args) future = self.future(*args)
return future() return future()
def future(self, *args) -> Any: def future(self, *args) -> Any:
"""Asynchronously calls JSON-RPC method.""" """Call JSON-RPC method asynchronously."""
request_id = next(self.rpc.id_iterator) request_id = next(self.rpc.id_iterator)
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
@@ -58,8 +65,13 @@ class RpcMethod:
class Rpc: class Rpc:
"""RPC client."""
def __init__(self, accounts_dir: Optional[str] = None, **kwargs): def __init__(self, accounts_dir: Optional[str] = None, **kwargs):
"""The given arguments will be passed to subprocess.Popen()""" """Initialize RPC client.
The given arguments will be passed to subprocess.Popen().
"""
if accounts_dir: if accounts_dir:
kwargs["env"] = { kwargs["env"] = {
**kwargs.get("env", os.environ), **kwargs.get("env", os.environ),
@@ -81,6 +93,7 @@ class Rpc:
self.events_thread: Thread self.events_thread: Thread
def start(self) -> None: def start(self) -> None:
"""Start RPC server subprocess."""
if sys.version_info >= (3, 11): if sys.version_info >= (3, 11):
self.process = subprocess.Popen( self.process = subprocess.Popen(
"deltachat-rpc-server", "deltachat-rpc-server",
@@ -130,6 +143,7 @@ class Rpc:
self.close() self.close()
def reader_loop(self) -> None: def reader_loop(self) -> None:
"""Process JSON-RPC responses from the RPC server process output."""
try: try:
while line := self.process.stdout.readline(): while line := self.process.stdout.readline():
response = json.loads(line) response = json.loads(line)
@@ -157,12 +171,13 @@ class Rpc:
logging.exception("Exception in the writer loop") logging.exception("Exception in the writer loop")
def get_queue(self, account_id: int) -> Queue: def get_queue(self, account_id: int) -> Queue:
"""Get event queue corresponding to the given account ID."""
if account_id not in self.event_queues: if account_id not in self.event_queues:
self.event_queues[account_id] = Queue() self.event_queues[account_id] = Queue()
return self.event_queues[account_id] return self.event_queues[account_id]
def events_loop(self) -> None: def events_loop(self) -> None:
"""Requests new events and distributes them between queues.""" """Request new events and distributes them between queues."""
try: try:
while True: while True:
if self.closing: if self.closing:
@@ -178,12 +193,12 @@ class Rpc:
logging.exception("Exception in the event loop") logging.exception("Exception in the event loop")
def wait_for_event(self, account_id: int) -> Optional[dict]: def wait_for_event(self, account_id: int) -> Optional[dict]:
"""Waits for the next event from the given account and returns it.""" """Wait for the next event from the given account and returns it."""
queue = self.get_queue(account_id) queue = self.get_queue(account_id)
return queue.get() return queue.get()
def clear_all_events(self, account_id: int): def clear_all_events(self, account_id: int):
"""Removes all queued-up events for a given account. Useful for tests.""" """Remove all queued-up events for a given account. Useful for tests."""
queue = self.get_queue(account_id) queue = self.get_queue(account_id)
try: try:
while True: while True: