diff --git a/python/jupyterlab-chat/jupyterlab_chat/models.py b/python/jupyterlab-chat/jupyterlab_chat/models.py new file mode 100644 index 0000000..e9d7b28 --- /dev/null +++ b/python/jupyterlab-chat/jupyterlab_chat/models.py @@ -0,0 +1,52 @@ +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +from dataclasses import dataclass +from typing import Literal, Optional +from jupyter_server.auth import User as JupyterUser + + +def message_asdict_factory(data): + """ Remove None values when converting Message to dict """ + return dict(x for x in data if x[1] is not None) + + +@dataclass(kw_only=True) +class Message: + """ Object representing a message """ + + type: Literal["msg"] = "msg" + body: str + """ The content of the message """ + + id: str + """ Unique ID """ + + time: float + """ Timestamp in second since epoch """ + + sender: str + """ The message sender unique id """ + + raw_time: Optional[bool] = None + """ + Whether the timestamp is raw (from client) or not (from server, unified) + Default to None + """ + + deleted: Optional[bool] = None + """ + Whether the message has been deleted or not (body should be empty if True) + Default to None. + """ + + edited: Optional[bool] = None + """ + Whether the message has been edited or not + Default to None. + """ + + +@dataclass(kw_only=True) +class User(JupyterUser): + """ Object representing a user (same as Jupyter User ) """ diff --git a/python/jupyterlab-chat/jupyterlab_chat/tests/test_ychat.py b/python/jupyterlab-chat/jupyterlab_chat/tests/test_ychat.py index f5934ab..27a15f4 100644 --- a/python/jupyterlab-chat/jupyterlab_chat/tests/test_ychat.py +++ b/python/jupyterlab-chat/jupyterlab_chat/tests/test_ychat.py @@ -4,108 +4,143 @@ # import jupyter_ydoc before YChat to avoid circular error import jupyter_ydoc +from dataclasses import asdict import pytest import time -from copy import deepcopy from uuid import uuid4 +from ..models import message_asdict_factory, Message, User from ..ychat import YChat -USER = { - "username": str(uuid4()), - "name": "Test user", - "display_name": "Test user" -} +USER = User( + username=str(uuid4()), + name="Test user", + display_name="Test user" +) -USER2 = { - "username": str(uuid4()), - "name": "Test user 2", - "display_name": "Test user 2" -} +USER2 = User( + username=str(uuid4()), + name="Test user 2", + display_name="Test user 2" +) def create_message(): - return { - "type": "msg", - "id": str(uuid4()), - "body": "This is a test message", - "time": time.time(), - "sender": USER["username"] -} + return Message( + type="msg", + id=str(uuid4()), + body="This is a test message", + time=time.time(), + sender=USER.username + ) def test_initialize_ychat(): - chat = YChat() - assert chat.get_messages() == [] - assert chat.get_users() == {} - assert chat.get_metadata() == {} + chat = YChat() + assert chat._get_messages() == [] + assert chat._get_users() == {} + assert chat.get_metadata() == {} def test_add_user(): - chat = YChat() - chat.set_user(USER) - assert USER["username"] in chat.get_users().keys() - assert chat.get_users()[USER["username"]] == USER + chat = YChat() + chat.set_user(USER) + assert USER.username in chat._get_users().keys() + assert chat._get_users()[USER.username] == asdict(USER) + + +def test_get_user_type(): + chat = YChat() + chat.set_user(USER) + assert isinstance(chat.get_user(USER.username), User) + + +def test_get_user(): + chat = YChat() + chat.set_user(USER) + chat.set_user(USER2) + assert chat.get_user(USER.username) == USER + assert chat.get_user(USER2.username) == USER2 + assert chat.get_user(str(uuid4())) == None + + +def test_get_user_by_name_type(): + chat = YChat() + chat.set_user(USER) + assert isinstance(chat.get_user_by_name(USER.name), User) def test_get_user_by_name(): - chat = YChat() - chat.set_user(USER) - chat.set_user(USER2) - assert chat.get_user_by_name(USER["name"]) == USER - assert chat.get_user_by_name(USER2["name"]) == USER2 - assert chat.get_user_by_name(str(uuid4())) == None + chat = YChat() + chat.set_user(USER) + chat.set_user(USER2) + assert chat.get_user_by_name(USER.name) == USER + assert chat.get_user_by_name(USER2.name) == USER2 + assert chat.get_user_by_name(str(uuid4())) == None def test_add_message(): - chat = YChat() - msg = create_message() - chat.add_message(msg) - assert len(chat.get_messages()) == 1 - assert chat.get_messages()[0] == msg + chat = YChat() + msg = create_message() + chat.add_message(msg) + assert len(chat._get_messages()) == 1 + assert chat._get_messages()[0] == asdict(msg, dict_factory=message_asdict_factory) + + +def test_get_message_type(): + chat = YChat() + msg = create_message() + chat.add_message(msg) + assert isinstance(chat.get_message(msg.id)[0], Message) + + +def test_get_message(): + chat = YChat() + msg = create_message() + chat.add_message(msg) + assert chat.get_message(msg.id) == (msg, 0) def test_set_message_should_add(): - chat = YChat() - msg = create_message() - chat.set_message(msg) - assert len(chat.get_messages()) == 1 - assert chat.get_messages()[0] == msg + chat = YChat() + msg = create_message() + chat.set_message(msg) + assert len(chat._get_messages()) == 1 + assert chat._get_messages()[0] == asdict(msg, dict_factory=message_asdict_factory) def test_set_message_should_update(): - chat = YChat() - msg = create_message() - index = chat.add_message(msg) - msg["body"] = "Updated content" - chat.set_message(msg, index) - assert len(chat.get_messages()) == 1 - assert chat.get_messages()[0] == msg + chat = YChat() + msg = create_message() + index = chat.add_message(msg) + msg.body = "Updated content" + chat.set_message(msg, index) + assert len(chat._get_messages()) == 1 + assert chat._get_messages()[0] == asdict(msg, dict_factory=message_asdict_factory) def test_set_message_should_add_with_new_id(): - chat = YChat() - msg = create_message() - index = chat.add_message(msg) - new_msg = deepcopy(msg) - new_msg["id"] = str(uuid4()) - new_msg["body"] = "Updated content" - chat.set_message(new_msg, index) - assert len(chat.get_messages()) == 2 - assert chat.get_messages()[0] == msg - assert chat.get_messages()[1] == new_msg + chat = YChat() + msg = create_message() + index = chat.add_message(msg) + new_msg = Message(**asdict(msg)) + new_msg.id = str(uuid4()) + new_msg.body = "Updated content" + chat.set_message(new_msg, index) + assert len(chat._get_messages()) == 2 + assert chat._get_messages()[0] == asdict(msg, dict_factory=message_asdict_factory) + assert chat._get_messages()[1] == asdict(new_msg, dict_factory=message_asdict_factory) def test_set_message_should_update_with_wrong_index(): - chat = YChat() - msg = create_message() - chat.add_message(msg) - new_msg = create_message() - new_msg["body"] = "New content" - index = chat.add_message(new_msg) - assert index == 1 - new_msg["body"] = "Updated content" - chat.set_message(new_msg, 0) - assert len(chat.get_messages()) == 2 - assert chat.get_messages()[0] == msg - assert chat.get_messages()[1] == new_msg - + chat = YChat() + msg = create_message() + chat.add_message(msg) + new_msg = create_message() + new_msg.body = "New content" + index = chat.add_message(new_msg) + assert index == 1 + new_msg.body = "Updated content" + chat.set_message(new_msg, 0) + assert len(chat._get_messages()) == 2 + assert chat._get_messages()[0] == asdict(msg, dict_factory=message_asdict_factory) + assert chat._get_messages()[1] == asdict(new_msg, dict_factory=message_asdict_factory) diff --git a/python/jupyterlab-chat/jupyterlab_chat/ychat.py b/python/jupyterlab-chat/jupyterlab_chat/ychat.py index 63f449e..20b2e86 100644 --- a/python/jupyterlab-chat/jupyterlab_chat/ychat.py +++ b/python/jupyterlab-chat/jupyterlab_chat/ychat.py @@ -3,6 +3,7 @@ # TODO: remove this module in favor of the one in jupyter_ydoc when released. +from dataclasses import asdict import json import time import asyncio @@ -12,6 +13,8 @@ from uuid import uuid4 from pycrdt import Array, ArrayEvent, Map, MapEvent +from .models import message_asdict_factory, Message, User + class YChat(YBaseDoc): def __init__(self, *args, **kwargs): @@ -52,105 +55,125 @@ def yusers(self) -> Map: def ymetadata(self) -> Map: return self._ymetadata - def get_user(self, username: str) -> Optional[dict[str, str]]: + def get_user(self, username: str) -> Optional[User]: """ - Returns a message from its id, or None + Returns a user from its id, or None """ return self.get_users().get(username, None) - def get_user_by_name(self, name: str) -> Optional[dict[str, str]]: + def get_user_by_name(self, name: str) -> Optional[User]: """ Returns a user from its name property, or None. """ return next( - (user for user in self.get_users().values() if user["name"] == name), + (user for user in self.get_users().values() if user.name == name), None ) - def get_users(self) -> dict[str, dict[str, str]]: + def get_users(self) -> dict[str, User]: """ Returns the users of the document. :return: Document's users. """ + user_dicts = self._get_users() + return {username: User(**user_dict) for username, user_dict in user_dicts.items()} + + def _get_users(self) -> dict[str, dict]: + """ + Returns the users of the document as dict. + """ return self._yusers.to_py() or {} - def set_user(self, user: dict[str, str]) -> None: + def set_user(self, user: User) -> None: """ Adds or modifies a user. """ with self._ydoc.transaction(): - self._yusers.update({user["username"]: user}) + self._yusers.update({ + user.username: asdict(user) + }) - def get_message(self, id: str) -> tuple[Optional[dict], Optional[int]]: + def get_message(self, id: str) -> tuple[Optional[Message], Optional[int]]: """ - Returns a message and its index from its id, or None + Returns a message and its index from its id, or None. """ return next( - ((msg, i) for i, msg in enumerate(self.get_messages()) if msg["id"] == id), + ((msg, i) for i, msg in enumerate(self.get_messages()) if msg.id == id), (None, None) ) - def get_messages(self) -> list[dict]: + def get_messages(self) -> list[Message]: """ Returns the messages of the document. - :return: Document's messages. + """ + message_dicts = self._get_messages() + return [Message(**message_dict) for message_dict in message_dicts] + + def _get_message_by_index(self, index: int): + """ + Return a message from its index. + """ + return self._ymessages[index] + + def _get_messages(self) -> list[dict]: + """ + Returns the messages of the document as dict. """ return self._ymessages.to_py() or [] - def add_message(self, message: dict) -> int: + def add_message(self, message: Message) -> int: """ Append a message to the document. """ timestamp: float = time.time() - message["time"] = timestamp + message.time = timestamp with self._ydoc.transaction(): - index = len(self._ymessages) - next((i for i, v in enumerate(self.get_messages()[::-1]) if v["time"] < timestamp), len(self._ymessages)) - self._ymessages.insert(index, message) + index = len(self._ymessages) - next((i for i, v in enumerate(self._get_messages()[::-1]) if v["time"] < timestamp), len(self._ymessages)) + self._ymessages.insert( + index, + asdict(message, dict_factory=message_asdict_factory) + ) return index - def update_message(self, message: dict, index: int, append: bool = False): + def update_message(self, message: Message, index: int, append: bool = False): """ Update a message of the document. If append is True, the content will be append to the previous content. """ with self._ydoc.transaction(): - initial_message = self._ymessages.pop(index) + initial_message: dict = self._ymessages.pop(index) if append: - message["body"] = initial_message["body"] + message["body"] - self._ymessages.insert(index, message) + message.body = initial_message["body"] + message.body + self._ymessages.insert( + index, + asdict(message, dict_factory=message_asdict_factory) + ) - def set_message(self, message: dict, index: Optional[int] = None, append: bool = False) -> int: + def set_message(self, message: Message, index: Optional[int] = None, append: bool = False) -> int: """ Update or append a message. """ - - initial_message: Optional[dict] = None + initial_message: Optional[Message] = None if index is not None and 0 <= index < len(self._ymessages): - initial_message = self.get_messages()[index] + initial_message = Message(**self._get_message_by_index(index)) else: return self.add_message(message) - if not initial_message["id"] == message["id"]: - initial_message, index = self.get_message(message["id"]) + if initial_message.id != message.id: + initial_message, index = self.get_message(message.id) if index is None: return self.add_message(message) self.update_message(message, index, append) return index - def get_single_metadata(self, name) -> dict: - """ - Return a single metadata. - """ - return self.get_metadata().get(name, {}) - - def get_metadata(self) -> dict[str, dict]: + def get_metadata(self) -> dict[str, Any]: """ Returns the metadata of the document. """ return self._ymetadata.to_py() or {} - def set_metadata(self, name: str, metadata: dict): + def set_metadata(self, name: str, metadata: Any): """ Adds or modifies a metadata of the document. """ @@ -183,11 +206,14 @@ def get(self) -> str: Returns the contents of the document. :return: Document's contents in JSON. """ - return json.dumps({ - "messages": self.get_messages(), - "users": self.get_users(), - "metadata": self.get_metadata() - }) + return json.dumps( + { + "messages": self._get_messages(), + "users": self._get_users(), + "metadata": self.get_metadata() + }, + indent=2 + ) def set(self, value: str) -> None: """ @@ -269,8 +295,8 @@ def _timestamp_new_messages(self, event: ArrayEvent) -> None: return for idx in range(index, index + inserted_count): - message = self.get_messages()[idx] - if message and message.get("raw_time", True): + message_dict = self._get_message_by_index(idx) + if message_dict and message_dict.get("raw_time", True): self.create_task(self._set_timestamp(idx, timestamp)) async def _set_timestamp(self, msg_idx: int, timestamp: float): @@ -280,19 +306,19 @@ async def _set_timestamp(self, msg_idx: int, timestamp: float): with self._ydoc.transaction(): # Remove the message from the list and modify the timestamp try: - message = self.get_messages()[msg_idx] + message_dict = self._get_message_by_index(msg_idx) except IndexError: return - message["time"] = timestamp - message["raw_time"] = False - self._ymessages[msg_idx] = message + message_dict["time"] = timestamp + message_dict["raw_time"] = False + self._ymessages[msg_idx] = message_dict # Move the message at the correct position in the list, looking first at the end, since the message # should be the last one. # The next() function below return the index of the first message with a timestamp inferior of the # current one, starting from the end of the list. - new_idx = len(self._ymessages) - next((i for i, v in enumerate(self.get_messages()[::-1]) if v["time"] < timestamp), len(self._ymessages)) + new_idx = len(self._ymessages) - next((i for i, v in enumerate(self._get_messages()[::-1]) if v["time"] < timestamp), len(self._ymessages)) if msg_idx != new_idx: - message = self._ymessages.pop(msg_idx) - self._ymessages.insert(new_idx, message) + message_dict = self._ymessages.pop(msg_idx) + self._ymessages.insert(new_idx, message_dict)