Begin yet another model refactor

Use native ListModel which require a lot of changes, but should be
much faster than the old way which exponentially slowed down to a crawl.
Also fix some popup bugs (leave/forget).

Not working yet: side pane keyboard controls, proper highlight,
room & member filtering, local echo replacement
This commit is contained in:
miruka
2019-12-02 16:29:29 -04:00
parent 2ce5e20efa
commit 9990fecc74
49 changed files with 826 additions and 781 deletions

View File

@@ -5,16 +5,16 @@ import logging as log
import sys
import traceback
from pathlib import Path
from typing import Any, DefaultDict, Dict, List, Optional, Tuple
from appdirs import AppDirs
from typing import Any, DefaultDict, Dict, List, Optional
import nio
from appdirs import AppDirs
from . import __app_name__
from .errors import MatrixError
from .matrix_client import MatrixClient
from .models.items import Account, Room
from .models import SyncId
from .models.items import Account
from .models.model_store import ModelStore
# Logging configuration
@@ -116,8 +116,8 @@ class Backend:
await client.close()
raise
self.clients[client.user_id] = client
self.models[Account][client.user_id] = Account(client.user_id)
self.clients[client.user_id] = client
self.models["accounts"][client.user_id] = Account(client.user_id)
return client.user_id
@@ -133,13 +133,13 @@ class Backend:
user=user_id, homeserver=homeserver, device_id=device_id,
)
self.clients[user_id] = client
self.models[Account][user_id] = Account(user_id)
self.clients[user_id] = client
self.models["accounts"][user_id] = Account(user_id)
await client.resume(user_id=user_id, token=token, device_id=device_id)
async def load_saved_accounts(self) -> Tuple[str, ...]:
async def load_saved_accounts(self) -> List[str]:
"""Call `resume_client` for all saved accounts in user config."""
async def resume(user_id: str, info: Dict[str, str]) -> str:
@@ -162,7 +162,7 @@ class Backend:
client = self.clients.pop(user_id, None)
if client:
self.models[Account].pop(user_id, None)
self.models["accounts"].pop(user_id, None)
await client.logout()
await self.saved_accounts.delete(user_id)
@@ -256,25 +256,24 @@ class Backend:
return (settings, ui_state, history, theme)
async def get_flat_mainpane_data(self) -> List[Dict[str, Any]]:
"""Return a flat list of accounts and their joined rooms for QML."""
async def await_model_item(
self, model_id: SyncId, item_id: Any,
) -> Dict[str, Any]:
data = []
if isinstance(model_id, list): # when called from QML
model_id = tuple(model_id)
for account in sorted(self.models[Account].values()):
data.append({
"type": "Account",
"id": account.user_id,
"user_id": account.user_id,
"data": account.serialized,
})
failures = 0
for room in sorted(self.models[Room, account.user_id].values()):
data.append({
"type": "Room",
"id": "/".join((account.user_id, room.room_id)),
"user_id": account.user_id,
"data": room.serialized,
})
while True:
try:
return self.models[model_id][item_id].serialized
except KeyError:
if failures and failures % 300 == 0:
log.warn(
"Item %r not found in model %r after %ds",
item_id, model_id, failures / 10,
)
return data
await asyncio.sleep(0.1)
failures += 1

View File

@@ -35,7 +35,7 @@ from .errors import (
)
from .html_markdown import HTML_PROCESSOR as HTML
from .models.items import (
Account, Event, Member, Room, TypeSpecifier, Upload, UploadStatus,
Event, Member, Room, TypeSpecifier, Upload, UploadStatus,
)
from .models.model_store import ModelStore
from .pyotherside_events import AlertRequested
@@ -211,7 +211,7 @@ class MatrixClient(nio.AsyncClient):
return
resp = future.result()
account = self.models[Account][self.user_id]
account = self.models["accounts"][self.user_id]
account.profile_updated = datetime.now()
account.display_name = resp.displayname or ""
account.avatar_url = resp.avatar_url or ""
@@ -284,7 +284,7 @@ class MatrixClient(nio.AsyncClient):
await self._send_file(item_uuid, room_id, path)
except (nio.TransferCancelledError, asyncio.CancelledError):
log.info("Deleting item for cancelled upload %s", item_uuid)
del self.models[Upload, room_id][str(item_uuid)]
del self.models[room_id, "uploads"][str(item_uuid)]
async def _send_file(
@@ -310,7 +310,7 @@ class MatrixClient(nio.AsyncClient):
task = asyncio.Task.current_task()
monitor = nio.TransferMonitor(size)
upload_item = Upload(item_uuid, task, monitor, path, total_size=size)
self.models[Upload, room_id][str(item_uuid)] = upload_item
self.models[room_id, "uploads"][str(item_uuid)] = upload_item
def on_transferred(transferred: int) -> None:
upload_item.uploaded = transferred
@@ -452,7 +452,7 @@ class MatrixClient(nio.AsyncClient):
content["msgtype"] = "m.file"
content["filename"] = path.name
del self.models[Upload, room_id][str(upload_item.uuid)]
del self.models[room_id, "uploads"][str(upload_item.id)]
await self._local_echo(
room_id,
@@ -499,12 +499,12 @@ class MatrixClient(nio.AsyncClient):
replace the local one we registered.
"""
our_info = self.models[Member, self.user_id, room_id][self.user_id]
our_info = self.models[self.user_id, room_id, "members"][self.user_id]
event = Event(
source = None,
client_id = f"echo-{transaction_id}",
id = f"echo-{transaction_id}",
event_id = "",
source = None,
date = datetime.now(),
sender_id = self.user_id,
sender_name = our_info.display_name,
@@ -514,13 +514,10 @@ class MatrixClient(nio.AsyncClient):
**event_fields,
)
for user_id in self.models[Account]:
if user_id in self.models[Member, self.user_id, room_id]:
for user_id in self.models["accounts"]:
if user_id in self.models[self.user_id, room_id, "members"]:
key = f"echo-{transaction_id}"
self.models[Event, user_id, room_id][key] = event
if user_id == self.user_id:
self.models[Event, user_id, room_id].sync_now()
self.models[user_id, room_id, "events"][key] = event
await self.set_room_last_event(room_id, event)
@@ -583,7 +580,7 @@ class MatrixClient(nio.AsyncClient):
async def load_rooms_without_visible_events(self) -> None:
"""Call `_load_room_without_visible_events` for all joined rooms."""
for room_id in self.models[Room, self.user_id]:
for room_id in self.models[self.user_id, "rooms"]:
asyncio.ensure_future(
self._load_room_without_visible_events(room_id),
)
@@ -604,7 +601,7 @@ class MatrixClient(nio.AsyncClient):
to show or there is nothing left to load.
"""
events = self.models[Event, self.user_id, room_id]
events = self.models[self.user_id, room_id, "events"]
more = True
while self.skipped_events[room_id] and not events and more:
@@ -685,11 +682,16 @@ class MatrixClient(nio.AsyncClient):
will be marked as suitable for destruction by the server.
"""
await super().room_leave(room_id)
self.models[self.user_id, "rooms"].pop(room_id, None)
self.models.pop((self.user_id, room_id, "events"), None)
self.models.pop((self.user_id, room_id, "members"), None)
try:
await super().room_leave(room_id)
except MatrixNotFound: # already left
pass
await super().room_forget(room_id)
self.models[Room, self.user_id].pop(room_id, None)
self.models.pop((Event, self.user_id, room_id), None)
self.models.pop((Member, self.user_id, room_id), None)
async def room_mass_invite(
@@ -843,7 +845,8 @@ class MatrixClient(nio.AsyncClient):
for sync_id, model in self.models.items():
if not (isinstance(sync_id, tuple) and
sync_id[0:2] == (Event, self.user_id)):
sync_id[0] == self.user_id and
sync_id[2] == "events"):
continue
_, _, room_id = sync_id
@@ -873,10 +876,9 @@ class MatrixClient(nio.AsyncClient):
"""
self.cleared_events_rooms.add(room_id)
model = self.models[Event, self.user_id, room_id]
model = self.models[self.user_id, room_id, "events"]
if model:
model.clear()
model.sync_now()
# Functions to register data into models
@@ -900,37 +902,10 @@ class MatrixClient(nio.AsyncClient):
The `last_event` is notably displayed in the UI room subtitles.
"""
model = self.models[Room, self.user_id]
room = model[room_id]
room = self.models[self.user_id, "rooms"][room_id]
if room.last_event is None:
room.last_event = item
if item.is_local_echo:
model.sync_now()
return
is_profile_ev = item.type_specifier == TypeSpecifier.profile_change
# If there were no better events available to show previously
prev_is_profile_ev = \
room.last_event.type_specifier == TypeSpecifier.profile_change
# If this is a profile event, only replace the currently shown one if
# it was also a profile event (we had nothing better to show).
if is_profile_ev and not prev_is_profile_ev:
return
# If this event is older than the currently shown one, only replace
# it if the previous was a profile event.
if item.date < room.last_event.date and not prev_is_profile_ev:
return
room.last_event = item
if item.is_local_echo:
model.sync_now()
if item.date > room.last_event_date:
room.last_event_date = item.date
async def register_nio_room(
@@ -939,18 +914,21 @@ class MatrixClient(nio.AsyncClient):
"""Register a `nio.MatrixRoom` as a `Room` object in our model."""
# Add room
try:
last_ev = self.models[Room, self.user_id][room.room_id].last_event
except KeyError:
last_ev = None
inviter = getattr(room, "inviter", "") or ""
levels = room.power_levels
can_send_state = partial(levels.can_user_send_state, self.user_id)
can_send_msg = partial(levels.can_user_send_message, self.user_id)
self.models[Room, self.user_id][room.room_id] = Room(
room_id = room.room_id,
try:
registered = self.models[self.user_id, "rooms"][room.room_id]
last_event_date = registered.last_event_date
typing_members = registered.typing_members
except KeyError:
last_event_date = datetime.fromtimestamp(0)
typing_members = []
self.models[self.user_id, "rooms"][room.room_id] = Room(
id = room.room_id,
given_name = room.name or "",
display_name = room.display_name or "",
avatar_url = room.gen_avatar_url or "",
@@ -962,6 +940,8 @@ class MatrixClient(nio.AsyncClient):
(room.avatar_url(inviter) or "") if inviter else "",
left = left,
typing_members = typing_members,
encrypted = room.encrypted,
invite_required = room.join_rule == "invite",
guests_allowed = room.guest_access == "can_join",
@@ -975,23 +955,24 @@ class MatrixClient(nio.AsyncClient):
can_set_join_rules = can_send_state("m.room.join_rules"),
can_set_guest_access = can_send_state("m.room.guest_access"),
last_event = last_ev,
last_event_date = last_event_date,
)
# List members that left the room, then remove them from our model
left_the_room = [
user_id
for user_id in self.models[Member, self.user_id, room.room_id]
for user_id in self.models[self.user_id, room.room_id, "members"]
if user_id not in room.users
]
for user_id in left_the_room:
del self.models[Member, self.user_id, room.room_id][user_id]
del self.models[self.user_id, room.room_id, "members"][user_id]
# Add the room members to the added room
new_dict = {
user_id: Member(
user_id = user_id,
id = user_id,
display_name = room.user_name(user_id) # disambiguated
if member.display_name else "",
avatar_url = member.avatar_url or "",
@@ -1000,7 +981,7 @@ class MatrixClient(nio.AsyncClient):
invited = member.invited,
) for user_id, member in room.users.items()
}
self.models[Member, self.user_id, room.room_id].update(new_dict)
self.models[self.user_id, room.room_id, "members"].update(new_dict)
async def get_member_name_avatar(
@@ -1013,7 +994,7 @@ class MatrixClient(nio.AsyncClient):
"""
try:
item = self.models[Member, self.user_id, room_id][user_id]
item = self.models[self.user_id, room_id, "members"][user_id]
except KeyError: # e.g. user is not anymore in the room
try:
@@ -1030,11 +1011,7 @@ class MatrixClient(nio.AsyncClient):
async def register_nio_event(
self, room: nio.MatrixRoom, ev: nio.Event, **fields,
) -> None:
"""Register a `nio.Event` as a `Event` object in our model.
`MatrixClient.register_nio_room` is called for the passed `room`
if neccessary before.
"""
"""Register a `nio.Event` as a `Event` object in our model."""
await self.register_nio_room(room)
@@ -1049,9 +1026,9 @@ class MatrixClient(nio.AsyncClient):
# Create Event ModelItem
item = Event(
source = ev,
client_id = ev.event_id,
id = ev.event_id,
event_id = ev.event_id,
source = ev,
date = datetime.fromtimestamp(ev.server_timestamp / 1000),
sender_id = ev.sender,
sender_name = sender_name,
@@ -1069,14 +1046,11 @@ class MatrixClient(nio.AsyncClient):
local_sender = ev.sender in self.backend.clients
if local_sender and tx_id:
item.client_id = f"echo-{tx_id}"
item.id = f"echo-{tx_id}"
if not local_sender and not await self.event_is_past(ev):
AlertRequested()
self.models[Event, self.user_id, room.room_id][item.client_id] = item
self.models[self.user_id, room.room_id, "events"][item.id] = item
await self.set_room_last_event(room.room_id, item)
if item.sender_id == self.user_id:
self.models[Event, self.user_id, room.room_id].sync_now()

View File

@@ -2,9 +2,6 @@
"""Provide classes related to data models shared between Python and QML."""
from typing import Tuple, Type, Union
from typing import Tuple, Union
from .model_item import ModelItem
# last one: Tuple[Union[Type[ModelItem], Tuple[Type[ModelItem]]], str...]
SyncId = Union[Type[ModelItem], Tuple[Type[ModelItem]], tuple]
SyncId = Union[str, Tuple[str]]

View File

@@ -11,31 +11,39 @@ from typing import Any, Dict, List, Optional, Tuple, Type, Union
from uuid import UUID
import lxml # nosec
import nio
from ..html_markdown import HTML_PROCESSOR
from ..utils import AutoStrEnum, auto
from .model_item import ModelItem
ZeroDate = datetime.fromtimestamp(0)
OptionalExceptionType = Union[Type[None], Type[Exception]]
class TypeSpecifier(AutoStrEnum):
"""Enum providing clarification of purpose for some matrix events."""
Unset = auto()
ProfileChange = auto()
MembershipChange = auto()
@dataclass
class Account(ModelItem):
"""A logged in matrix account."""
user_id: str = field()
display_name: str = ""
avatar_url: str = ""
first_sync_done: bool = False
profile_updated: Optional[datetime] = None
id: str = field()
display_name: str = ""
avatar_url: str = ""
first_sync_done: bool = False
profile_updated: datetime = ZeroDate
def __lt__(self, other: "Account") -> bool:
"""Sort by display name or user ID."""
name = self.display_name or self.user_id[1:]
other_name = other.display_name or other.user_id[1:]
return name < other_name
name = self.display_name or self.id[1:]
other_name = other.display_name or other.id[1:]
return name.lower() < other_name.lower()
@property
def filter_string(self) -> str:
@@ -47,18 +55,21 @@ class Account(ModelItem):
class Room(ModelItem):
"""A matrix room we are invited to, are or were member of."""
room_id: str = field()
given_name: str = ""
display_name: str = ""
avatar_url: str = ""
plain_topic: str = ""
topic: str = ""
inviter_id: str = ""
inviter_name: str = ""
inviter_avatar: str = ""
left: bool = False
id: str = field()
given_name: str = ""
display_name: str = ""
main_alias: str = ""
avatar_url: str = ""
plain_topic: str = ""
topic: str = ""
inviter_id: str = ""
inviter_name: str = ""
inviter_avatar: str = ""
left: bool = False
typing_members: List[str] = field(default_factory=list)
federated: bool = True
encrypted: bool = False
invite_required: bool = True
guests_allowed: bool = True
@@ -72,7 +83,7 @@ class Room(ModelItem):
can_set_join_rules: bool = False
can_set_guest_access: bool = False
last_event: Optional["Event"] = field(default=None, repr=False)
last_event_date: datetime = ZeroDate
def __lt__(self, other: "Room") -> bool:
"""Sort by join state, then descending last event date, then name.
@@ -85,68 +96,45 @@ class Room(ModelItem):
# Left rooms may still have an inviter_id, so check left first.
return (
self.left,
other.inviter_id,
other.last_event.date if other.last_event else
datetime.fromtimestamp(0),
self.display_name.lower() or self.room_id,
other.last_event_date,
(self.display_name or self.id).lower(),
) < (
other.left,
self.inviter_id,
self.last_event.date if self.last_event else
datetime.fromtimestamp(0),
other.display_name.lower() or other.room_id,
self.last_event_date,
(other.display_name or other.id).lower(),
)
@property
def filter_string(self) -> str:
"""Filter based on room display name, topic, and last event content."""
return " ".join((
self.display_name,
self.topic,
re.sub(r"<.*?>", "", self.last_event.inline_content)
if self.last_event else "",
))
@property
def serialized(self) -> Dict[str, Any]:
dct = super().serialized
if self.last_event is not None:
dct["last_event"] = self.last_event.serialized
return dct
return " ".join((self.display_name, self.topic))
@dataclass
class Member(ModelItem):
"""A member in a matrix room."""
user_id: str = field()
display_name: str = ""
avatar_url: str = ""
typing: bool = False
power_level: int = 0
invited: bool = False
id: str = field()
display_name: str = ""
avatar_url: str = ""
typing: bool = False
power_level: int = 0
invited: bool = False
profile_updated: datetime = ZeroDate
def __lt__(self, other: "Member") -> bool:
"""Sort by power level, then by display name/user ID."""
name = (self.display_name or self.user_id[1:]).lower()
other_name = (other.display_name or other.user_id[1:]).lower()
name = self.display_name or self.id[1:]
other_name = other.display_name or other.id[1:]
return (
self.invited, other.power_level, name,
self.invited, other.power_level, name.lower(),
) < (
other.invited, self.power_level, other_name,
other.invited, self.power_level, other_name.lower(),
)
@@ -168,15 +156,15 @@ class UploadStatus(AutoStrEnum):
class Upload(ModelItem):
"""Represent a running or failed file upload operation."""
uuid: UUID = field()
id: UUID = field()
task: asyncio.Task = field()
monitor: nio.TransferMonitor = field()
filepath: Path = field()
total_size: int = 0
uploaded: int = 0
speed: float = 0
time_left: Optional[timedelta] = None
total_size: int = 0
uploaded: int = 0
speed: float = 0
time_left: timedelta = timedelta(0)
status: UploadStatus = UploadStatus.Uploading
error: OptionalExceptionType = type(None)
@@ -191,21 +179,13 @@ class Upload(ModelItem):
return self.start_date > other.start_date
class TypeSpecifier(AutoStrEnum):
"""Enum providing clarification of purpose for some matrix events."""
none = auto()
profile_change = auto()
membership_change = auto()
@dataclass
class Event(ModelItem):
"""A matrix state event or message."""
source: Optional[nio.Event] = field()
client_id: str = field()
id: str = field()
event_id: str = field()
source: Optional[nio.Event] = field()
date: datetime = field()
sender_id: str = field()
sender_name: str = field()
@@ -213,8 +193,9 @@ class Event(ModelItem):
content: str = ""
inline_content: str = ""
reason: str = ""
type_specifier: TypeSpecifier = TypeSpecifier.none
type_specifier: TypeSpecifier = TypeSpecifier.Unset
target_id: str = ""
target_name: str = ""
@@ -271,12 +252,19 @@ class Event(ModelItem):
return urls
@property
def serialized(self) -> Dict[str, Any]:
dct = super().serialized
del dct["source"]
del dct["local_event_type"]
return dct
@dataclass
class Device(ModelItem):
"""A matrix user's device. This class is currently unused."""
device_id: str = field()
id: str = field()
ed25519_key: str = field()
trusted: bool = False
blacklisted: bool = False

View File

@@ -1,12 +1,12 @@
# SPDX-License-Identifier: LGPL-3.0-or-later
import logging as log
import time
from threading import Lock, Thread
from threading import Lock
from typing import Any, Dict, Iterator, List, MutableMapping
from ..pyotherside_events import (
ModelCleared, ModelItemDeleted, ModelItemInserted,
)
from . import SyncId
from ..pyotherside_events import ModelUpdated
from .model_item import ModelItem
@@ -30,13 +30,10 @@ class Model(MutableMapping):
"""
def __init__(self, sync_id: SyncId) -> None:
self.sync_id: SyncId = sync_id
self._data: Dict[Any, ModelItem] = {}
self._changed: bool = False
self._sync_lock: Lock = Lock()
self._sync_thread: Thread = Thread(target=self._sync_loop, daemon=True)
self._sync_thread.start()
self.sync_id: SyncId = sync_id
self._data: Dict[Any, ModelItem] = {}
self._sorted_data: List[ModelItem] = []
self._write_lock: Lock = Lock()
def __repr__(self) -> str:
@@ -47,27 +44,14 @@ class Model(MutableMapping):
except ImportError:
from pprint import pformat # type: ignore
if isinstance(self.sync_id, tuple):
sid = (self.sync_id[0].__name__, *self.sync_id[1:])
else:
sid = self.sync_id.__name__ # type: ignore
return "%s(sync_id=%s, %s)" % (
type(self).__name__, sid, pformat(self._data),
type(self).__name__, self.sync_id, pformat(self._data),
)
def __str__(self) -> str:
"""Provide a short "<sync_id>: <num> items" representation."""
if isinstance(self.sync_id, tuple):
reprs = tuple(repr(s) for s in self.sync_id[1:])
sid = ", ".join((self.sync_id[0].__name__, *reprs))
sid = f"({sid})"
else:
sid = self.sync_id.__name__
return f"{sid!s}: {len(self)} items"
return f"{self.sync_id}: {len(self)} items"
def __getitem__(self, key):
@@ -81,37 +65,36 @@ class Model(MutableMapping):
updated with the passed `ModelItem`'s fields.
In other cases, the item is simply added to the model.
This also sets the `ModelItem.parent_model` hidden attribute on the
passed item.
This also sets the `ModelItem.parent_model` hidden attributes on
the passed item.
"""
new = value
with self._write_lock:
existing = self._data.get(key)
new = value
if key in self:
existing = dict(self[key].serialized) # copy to not alter with pop
merged = {**existing, **value.serialized}
existing.pop("parent_model", None)
merged.pop("parent_model", None)
if merged == existing:
if existing:
for field in new.__dataclass_fields__: # type: ignore
# The same shared item is in _sorted_data, no need to find
# and modify it explicitely.
setattr(existing, field, getattr(new, field))
return
merged_init_kwargs = {**vars(self[key]), **vars(value)}
merged_init_kwargs.pop("parent_model", None)
new = type(value)(**merged_init_kwargs)
new.parent_model = self
new.parent_model = self
with self._sync_lock:
self._data[key] = new
self._changed = True
self._sorted_data.append(new)
self._sorted_data.sort()
ModelItemInserted(self.sync_id, self._sorted_data.index(new), new)
def __delitem__(self, key) -> None:
with self._sync_lock:
del self._data[key]
self._changed = True
with self._write_lock:
item = self._data.pop(key)
index = self._sorted_data.index(item)
del self._sorted_data[index]
ModelItemDeleted(self.sync_id, index)
def __iter__(self) -> Iterator:
@@ -122,31 +105,11 @@ class Model(MutableMapping):
return len(self._data)
def _sync_loop(self) -> None:
"""Loop to synchronize model when needed with a cooldown of 0.25s."""
while True:
time.sleep(0.25)
if self._changed:
with self._sync_lock:
log.debug("Syncing %s", self)
self.sync_now()
def sync_now(self) -> None:
"""Trigger a model synchronization right now. Use with precaution."""
ModelUpdated(self.sync_id, self.serialized())
self._changed = False
def serialized(self) -> List[Dict[str, Any]]:
"""Return serialized model content as a list of dict for QML."""
return [item.serialized for item in sorted(self._data.values())]
def __lt__(self, other: "Model") -> bool:
"""Sort `Model` objects lexically by `sync_id`."""
return str(self.sync_id) < str(other.sync_id)
def clear(self) -> None:
super().clear()
ModelCleared(self.sync_id)

View File

@@ -2,8 +2,6 @@
from typing import Any, Dict, Optional
from ..utils import serialize_value_for_qml
class ModelItem:
"""Base class for items stored inside a `Model`.
@@ -28,11 +26,23 @@ class ModelItem:
def __setattr__(self, name: str, value) -> None:
"""If this item is in a `Model`, alert it of attribute changes."""
if name == "parent_model" or self.parent_model is None:
super().__setattr__(name, value)
return
if getattr(self, name) == value:
return
super().__setattr__(name, value)
if name != "parent_model" and self.parent_model is not None:
with self.parent_model._sync_lock:
self.parent_model._changed = True
old_index = self.parent_model._sorted_data.index(self)
self.parent_model._sorted_data.sort()
new_index = self.parent_model._sorted_data.index(self)
from ..pyotherside_events import ModelItemFieldChanged
ModelItemFieldChanged(
self.parent_model.sync_id, old_index, new_index, name, value,
)
def __delattr__(self, name: str) -> None:
@@ -43,8 +53,10 @@ class ModelItem:
def serialized(self) -> Dict[str, Any]:
"""Return this item as a dict ready to be passed to QML."""
from ..utils import serialize_value_for_qml
return {
name: serialize_value_for_qml(getattr(self, name))
name: serialize_value_for_qml(getattr(self, name), json_lists=True)
for name in dir(self)
if not (
name.startswith("_") or name in ("parent_model", "serialized")

View File

@@ -14,7 +14,7 @@ import nio
from . import utils
from .html_markdown import HTML_PROCESSOR
from .matrix_client import MatrixClient
from .models.items import Account, Room, TypeSpecifier
from .models.items import TypeSpecifier
@dataclass
@@ -63,7 +63,6 @@ class NioCallbacks:
# TODO: handle in nio, these are rooms that were left before
# starting the client.
if room_id not in self.client.all_rooms:
log.warning("Left room not in MatrixClient.rooms: %r", room_id)
continue
# TODO: handle left events in nio async client
@@ -85,7 +84,7 @@ class NioCallbacks:
self.client.first_sync_done.set()
self.client.first_sync_date = datetime.now()
account = self.client.models[Account][self.client.user_id]
account = self.client.models["accounts"][self.client.user_id]
account.first_sync_done = True
@@ -207,7 +206,7 @@ class NioCallbacks:
prev_membership = ev.prev_membership
ev_date = datetime.fromtimestamp(ev.server_timestamp / 1000)
member_change = TypeSpecifier.membership_change
member_change = TypeSpecifier.MembershipChange
# Membership changes
if not prev or membership != prev_membership:
@@ -263,10 +262,9 @@ class NioCallbacks:
if changed:
# Update our account profile if the event is newer than last update
if ev.state_key == self.client.user_id:
account = self.client.models[Account][self.client.user_id]
updated = account.profile_updated
account = self.client.models["accounts"][self.client.user_id]
if not updated or updated < ev_date:
if account.profile_updated < ev_date:
account.profile_updated = ev_date
account.display_name = now["displayname"] or ""
account.avatar_url = now["avatar_url"] or ""
@@ -276,7 +274,7 @@ class NioCallbacks:
return None
return (
TypeSpecifier.profile_change,
TypeSpecifier.ProfileChange,
"%1 changed their {}".format(" and ".join(changed)),
)
@@ -383,10 +381,11 @@ class NioCallbacks:
if not self.client.first_sync_done.is_set():
return
if room.room_id not in self.client.models[Room, self.client.user_id]:
return
await self.client.register_nio_room(room)
room_item = self.client.models[Room, self.client.user_id][room.room_id]
room_id = room.room_id
room_item = self.client.models[self.client.user_id, "rooms"][room_id]
room_item.typing_members = sorted(
room.user_name(user_id) for user_id in ev.users

View File

@@ -1,11 +1,13 @@
# SPDX-License-Identifier: LGPL-3.0-or-later
from abc import ABC
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Union
from typing import Any, Optional
import pyotherside
from .models import SyncId
from .models.model_item import ModelItem
from .utils import serialize_value_for_qml
@@ -13,11 +15,13 @@ from .utils import serialize_value_for_qml
class PyOtherSideEvent:
"""Event that will be sent on instanciation to QML by PyOtherSide."""
json_lists = False
def __post_init__(self) -> None:
# CPython 3.6 or any Python implemention >= 3.7 is required for correct
# __dataclass_fields__ dict order.
args = [
serialize_value_for_qml(getattr(self, field))
serialize_value_for_qml(getattr(self, field), self.json_lists)
for field in self.__dataclass_fields__ # type: ignore
]
pyotherside.send(type(self).__name__, *args)
@@ -59,20 +63,32 @@ class LoopException(PyOtherSideEvent):
@dataclass
class ModelUpdated(PyOtherSideEvent):
"""Indicate that a backend `Model`'s data changed."""
class ModelEvent(ABC, PyOtherSideEvent):
json_lists = True
sync_id: SyncId = field()
data: List[Dict[str, Any]] = field()
serialized_sync_id: Union[str, List[str]] = field(init=False)
@dataclass
class ModelItemInserted(ModelEvent):
sync_id: SyncId = field()
index: int = field()
item: ModelItem = field()
def __post_init__(self) -> None:
if isinstance(self.sync_id, tuple):
self.serialized_sync_id = [
e.__name__ if isinstance(e, type) else e for e in self.sync_id
]
else:
self.serialized_sync_id = self.sync_id.__name__
super().__post_init__()
@dataclass
class ModelItemFieldChanged(ModelEvent):
sync_id: SyncId = field()
item_index_then: int = field()
item_index_now: int = field()
changed_field: str = field()
field_value: Any = field()
@dataclass
class ModelItemDeleted(ModelEvent):
sync_id: SyncId = field()
index: int = field()
@dataclass
class ModelCleared(ModelEvent):
sync_id: SyncId = field()

View File

@@ -110,8 +110,6 @@ class QMLBridge:
rc = lambda c: asyncio.run_coroutine_threadsafe(c, self._loop) # noqa
from .models.items import Account, Room, Member, Event, Device # noqa
p = print # pdb's `p` doesn't print a class's __str__ # noqa
try:
from pprintpp import pprint as pp # noqa

View File

@@ -6,6 +6,7 @@ import collections
import html
import inspect
import io
import json
import xml.etree.cElementTree as xml_etree # FIXME: bandit warning
from datetime import timedelta
from enum import Enum
@@ -17,10 +18,11 @@ from uuid import UUID
import filetype
from aiofiles.threadpool.binary import AsyncBufferedReader
from nio.crypto import AsyncDataT as File
from nio.crypto import async_generator_from_data
from .models.model_item import ModelItem
Size = Tuple[int, int]
auto = autostr
@@ -125,7 +127,7 @@ def plain2html(text: str) -> str:
.replace("\t", "&nbsp;" * 4)
def serialize_value_for_qml(value: Any) -> Any:
def serialize_value_for_qml(value: Any, json_lists: bool = False) -> Any:
"""Convert a value to make it easier to use from QML.
Returns:
@@ -135,11 +137,18 @@ def serialize_value_for_qml(value: Any) -> Any:
- Strings for `UUID` objects
- A number of milliseconds for `datetime.timedelta` objects
- The class `__name__` for class types.
- `ModelItem.serialized` for `ModelItem`s
"""
if json_lists and isinstance(value, list):
return json.dumps(value)
if hasattr(value, "__class__") and issubclass(value.__class__, Enum):
return value.value
if isinstance(value, ModelItem):
return value.serialized
if isinstance(value, Path):
return f"file://{value!s}"