From fa2e84b9866cb8ee21e52361ba86f5d8d18b3b5b Mon Sep 17 00:00:00 2001 From: Adir Amsalem Date: Mon, 9 Feb 2026 09:50:23 +0200 Subject: [PATCH] feat(realtime): port set() method with timeout updates and cleanup - Port set() method to realtime client with proper timeout handling - Convert SetInput from dataclass to Pydantic BaseModel for validation - Remove unused SetAvatarImageMessage import - Update image URL parsing with proper data URI handling - Add comprehensive unit tests for set() method - Update webrtc_manager with timeout configuration --- decart/__init__.py | 3 + decart/realtime/__init__.py | 3 +- decart/realtime/client.py | 98 ++++--- decart/realtime/messages.py | 6 +- decart/realtime/webrtc_connection.py | 4 +- decart/realtime/webrtc_manager.py | 40 +++ tests/test_realtime_unit.py | 386 +++++++++++++++++++++++---- uv.lock | 2 +- 8 files changed, 452 insertions(+), 90 deletions(-) diff --git a/decart/__init__.py b/decart/__init__.py index 452a3dc..f4e7260 100644 --- a/decart/__init__.py +++ b/decart/__init__.py @@ -29,6 +29,7 @@ try: from .realtime import ( RealtimeClient, + SetInput, RealtimeConnectOptions, ConnectionState, AvatarOptions, @@ -38,6 +39,7 @@ except ImportError: REALTIME_AVAILABLE = False RealtimeClient = None # type: ignore + SetInput = None # type: ignore RealtimeConnectOptions = None # type: ignore ConnectionState = None # type: ignore AvatarOptions = None # type: ignore @@ -76,6 +78,7 @@ __all__.extend( [ "RealtimeClient", + "SetInput", "RealtimeConnectOptions", "ConnectionState", "AvatarOptions", diff --git a/decart/realtime/__init__.py b/decart/realtime/__init__.py index cb20fd2..0a98cab 100644 --- a/decart/realtime/__init__.py +++ b/decart/realtime/__init__.py @@ -1,8 +1,9 @@ -from .client import RealtimeClient +from .client import RealtimeClient, SetInput from .types import RealtimeConnectOptions, ConnectionState, AvatarOptions __all__ = [ "RealtimeClient", + "SetInput", "RealtimeConnectOptions", "ConnectionState", "AvatarOptions", diff --git a/decart/realtime/client.py b/decart/realtime/client.py index 4411a56..621d8d8 100644 --- a/decart/realtime/client.py +++ b/decart/realtime/client.py @@ -1,13 +1,16 @@ -from typing import Callable, Optional +from typing import Callable, Optional, Union import asyncio import base64 import logging import uuid +from pathlib import Path +from urllib.parse import urlparse import aiohttp from aiortc import MediaStreamTrack +from pydantic import BaseModel from .webrtc_manager import WebRTCManager, WebRTCConfiguration -from .messages import PromptMessage, SetAvatarImageMessage +from .messages import PromptMessage from .types import ConnectionState, RealtimeConnectOptions from ..types import FileInput from ..errors import DecartSDKError, InvalidInputError, WebRTCError @@ -15,6 +18,44 @@ logger = logging.getLogger(__name__) +PROMPT_TIMEOUT_S = 15.0 +UPDATE_TIMEOUT_S = 30.0 + + +class SetInput(BaseModel): + prompt: Optional[str] = None + enhance: bool = True + image: Optional[Union[bytes, str]] = None + + +async def _image_to_base64( + image: Union[bytes, str], + http_session: aiohttp.ClientSession, +) -> str: + if isinstance(image, bytes): + return base64.b64encode(image).decode("utf-8") + + if isinstance(image, str): + parsed = urlparse(image) + + if parsed.scheme == "data": + return image.split(",", 1)[1] + + if parsed.scheme in ("http", "https"): + async with http_session.get(image) as resp: + resp.raise_for_status() + data = await resp.read() + return base64.b64encode(data).decode("utf-8") + + if Path(image).exists(): + image_bytes, _ = await file_input_to_bytes(image, http_session) + return base64.b64encode(image_bytes).decode("utf-8") + + return image + + image_bytes, _ = await file_input_to_bytes(image, http_session) + return base64.b64encode(image_bytes).decode("utf-8") + class RealtimeClient: def __init__( @@ -124,6 +165,28 @@ def _emit_error(self, error: DecartSDKError) -> None: except Exception as e: logger.exception(f"Error in error callback: {e}") + async def set(self, input: SetInput) -> None: + if input.prompt is None and input.image is None: + raise InvalidInputError("At least one of 'prompt' or 'image' must be provided") + + if input.prompt is not None and not input.prompt.strip(): + raise InvalidInputError("Prompt cannot be empty") + + image_base64: Optional[str] = None + if input.image is not None: + if not self._http_session: + raise InvalidInputError("HTTP session not available") + image_base64 = await _image_to_base64(input.image, self._http_session) + + await self._manager.set_image( + image_base64, + { + "prompt": input.prompt, + "enhance": input.enhance, + "timeout": UPDATE_TIMEOUT_S, + }, + ) + async def set_prompt(self, prompt: str, enrich: bool = True) -> None: if not prompt or not prompt.strip(): raise InvalidInputError("Prompt cannot be empty") @@ -136,7 +199,7 @@ async def set_prompt(self, prompt: str, enrich: bool = True) -> None: ) try: - await asyncio.wait_for(event.wait(), timeout=15.0) + await asyncio.wait_for(event.wait(), timeout=PROMPT_TIMEOUT_S) except asyncio.TimeoutError: raise DecartSDKError("Prompt acknowledgment timed out") @@ -146,43 +209,16 @@ async def set_prompt(self, prompt: str, enrich: bool = True) -> None: self._manager.unregister_prompt_wait(prompt) async def set_image(self, image: FileInput) -> None: - """Set or update the avatar image. - - Only available for avatar-live model. - - Args: - image: The image to set. Can be bytes, Path, URL string, or file-like object. - - Raises: - InvalidInputError: If not using avatar-live model or image is invalid. - DecartSDKError: If the server fails to acknowledge the image. - """ if not self._is_avatar_live: raise InvalidInputError("set_image() is only available for avatar-live model") if not self._http_session: raise InvalidInputError("HTTP session not available") - # Convert image to base64 image_bytes, _ = await file_input_to_bytes(image, self._http_session) image_base64 = base64.b64encode(image_bytes).decode("utf-8") - event, result = self._manager.register_image_set_wait() - - try: - await self._manager.send_message( - SetAvatarImageMessage(type="set_image", image_data=image_base64) - ) - - try: - await asyncio.wait_for(event.wait(), timeout=15.0) - except asyncio.TimeoutError: - raise DecartSDKError("Image set acknowledgment timed out") - - if not result["success"]: - raise DecartSDKError(result.get("error") or "Failed to set avatar image") - finally: - self._manager.unregister_image_set_wait() + await self._manager.set_image(image_base64) def is_connected(self) -> bool: return self._manager.is_connected() diff --git a/decart/realtime/messages.py b/decart/realtime/messages.py index 287073f..1e201e6 100644 --- a/decart/realtime/messages.py +++ b/decart/realtime/messages.py @@ -128,7 +128,9 @@ class SetAvatarImageMessage(BaseModel): """Set avatar image message.""" type: Literal["set_image"] - image_data: str # Base64-encoded image + image_data: Optional[str] = None + prompt: Optional[str] = None + enhance_prompt: Optional[bool] = None # Outgoing message union (no discriminator needed - we know what we're sending) @@ -161,4 +163,4 @@ def message_to_json(message: OutgoingMessage) -> str: Returns: JSON string """ - return message.model_dump_json() + return message.model_dump_json(exclude_none=True) diff --git a/decart/realtime/webrtc_connection.py b/decart/realtime/webrtc_connection.py index f99d14a..e561c6b 100644 --- a/decart/realtime/webrtc_connection.py +++ b/decart/realtime/webrtc_connection.py @@ -59,7 +59,7 @@ async def connect( self, url: str, local_track: Optional[MediaStreamTrack], - timeout: float = 30, + timeout: float, integration: Optional[str] = None, is_avatar_live: bool = False, avatar_image_base64: Optional[str] = None, @@ -107,7 +107,7 @@ async def connect( self._on_error(e) raise WebRTCError(str(e), cause=e) - async def _send_avatar_image_and_wait(self, image_base64: str, timeout: float = 15.0) -> None: + async def _send_avatar_image_and_wait(self, image_base64: str, timeout: float = 30.0) -> None: """Send avatar image and wait for acknowledgment.""" event, result = self.register_image_set_wait() diff --git a/decart/realtime/webrtc_manager.py b/decart/realtime/webrtc_manager.py index f9f0764..20ed852 100644 --- a/decart/realtime/webrtc_manager.py +++ b/decart/realtime/webrtc_manager.py @@ -60,9 +60,11 @@ async def connect( initial_prompt: Optional[dict] = None, ) -> bool: try: + timeout = 60 * 5 # 5 minutes await self._connection.connect( url=self._config.webrtc_url, local_track=local_track, + timeout=timeout, integration=self._config.integration, is_avatar_live=self._config.is_avatar_live, avatar_image_base64=avatar_image_base64, @@ -83,6 +85,44 @@ def _create_connection(self) -> WebRTCConnection: customize_offer=self._config.customize_offer, ) + async def set_image( + self, + image_base64: Optional[str], + options: Optional[dict] = None, + ) -> None: + from .messages import SetAvatarImageMessage + + opts = options or {} + timeout = opts.get("timeout", 30.0) + + event, result = self._connection.register_image_set_wait() + + try: + message = SetAvatarImageMessage( + type="set_image", + image_data=image_base64, + ) + if opts.get("prompt") is not None: + message.prompt = opts["prompt"] + if opts.get("enhance") is not None: + message.enhance_prompt = opts["enhance"] + + await self._connection.send(message) + + try: + await asyncio.wait_for(event.wait(), timeout=timeout) + except asyncio.TimeoutError: + from ..errors import DecartSDKError + + raise DecartSDKError("Image send timed out") + + if not result["success"]: + from ..errors import DecartSDKError + + raise DecartSDKError(result.get("error") or "Failed to set image") + finally: + self._connection.unregister_image_set_wait() + async def send_message(self, message: OutgoingMessage) -> None: await self._connection.send(message) diff --git a/tests/test_realtime_unit.py b/tests/test_realtime_unit.py index 511b1b1..1c0e4b0 100644 --- a/tests/test_realtime_unit.py +++ b/tests/test_realtime_unit.py @@ -355,7 +355,6 @@ async def test_avatar_live_connect_with_avatar_image(): @pytest.mark.asyncio async def test_avatar_live_set_image(): """Test set_image method for avatar-live""" - import asyncio client = DecartClient(api_key="test-key") @@ -366,15 +365,7 @@ async def test_avatar_live_set_image(): ): mock_manager = AsyncMock() mock_manager.connect = AsyncMock(return_value=True) - mock_manager.send_message = AsyncMock() - - image_set_event = asyncio.Event() - image_set_result = {"success": True, "error": None} - - mock_manager.register_image_set_wait = MagicMock( - return_value=(image_set_event, image_set_result) - ) - mock_manager.unregister_image_set_wait = MagicMock() + mock_manager.set_image = AsyncMock() mock_manager_class.return_value = mock_manager mock_file_input.return_value = (b"new image data", "image/png") @@ -398,18 +389,11 @@ async def test_avatar_live_set_image(): ), ) - async def set_event(): - await asyncio.sleep(0.01) - image_set_event.set() - - asyncio.create_task(set_event()) await realtime_client.set_image(b"new avatar image") - mock_manager.send_message.assert_called() - call_args = mock_manager.send_message.call_args[0][0] - assert call_args.type == "set_image" - assert call_args.image_data is not None - mock_manager.unregister_image_set_wait.assert_called_once() + mock_manager.set_image.assert_called_once() + image_base64_arg = mock_manager.set_image.call_args[0][0] + assert image_base64_arg is not None @pytest.mark.asyncio @@ -453,7 +437,6 @@ async def test_set_image_only_for_avatar_live(): @pytest.mark.asyncio async def test_avatar_live_set_image_timeout(): """Test set_image raises on timeout""" - import asyncio client = DecartClient(api_key="test-key") @@ -462,17 +445,11 @@ async def test_avatar_live_set_image_timeout(): patch("decart.realtime.client.file_input_to_bytes") as mock_file_input, patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls, ): + from decart.errors import DecartSDKError + mock_manager = AsyncMock() mock_manager.connect = AsyncMock(return_value=True) - mock_manager.send_message = AsyncMock() - - image_set_event = asyncio.Event() - image_set_result = {"success": False, "error": None} - - mock_manager.register_image_set_wait = MagicMock( - return_value=(image_set_event, image_set_result) - ) - mock_manager.unregister_image_set_wait = MagicMock() + mock_manager.set_image = AsyncMock(side_effect=DecartSDKError("Image send timed out")) mock_manager_class.return_value = mock_manager mock_file_input.return_value = (b"image data", "image/png") @@ -485,7 +462,6 @@ async def test_avatar_live_set_image_timeout(): mock_track = MagicMock() from decart.realtime.types import RealtimeConnectOptions - from decart.errors import DecartSDKError realtime_client = await RealtimeClient.connect( base_url=client.base_url, @@ -497,18 +473,15 @@ async def test_avatar_live_set_image_timeout(): ), ) - with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError): - with pytest.raises(DecartSDKError) as exc_info: - await realtime_client.set_image(b"test image") + with pytest.raises(DecartSDKError) as exc_info: + await realtime_client.set_image(b"test image") assert "timed out" in str(exc_info.value).lower() - mock_manager.unregister_image_set_wait.assert_called_once() @pytest.mark.asyncio async def test_avatar_live_set_image_server_error(): """Test set_image raises on server error""" - import asyncio client = DecartClient(api_key="test-key") @@ -517,17 +490,11 @@ async def test_avatar_live_set_image_server_error(): patch("decart.realtime.client.file_input_to_bytes") as mock_file_input, patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls, ): + from decart.errors import DecartSDKError + mock_manager = AsyncMock() mock_manager.connect = AsyncMock(return_value=True) - mock_manager.send_message = AsyncMock() - - image_set_event = asyncio.Event() - image_set_result = {"success": False, "error": "Invalid image format"} - - mock_manager.register_image_set_wait = MagicMock( - return_value=(image_set_event, image_set_result) - ) - mock_manager.unregister_image_set_wait = MagicMock() + mock_manager.set_image = AsyncMock(side_effect=DecartSDKError("Invalid image format")) mock_manager_class.return_value = mock_manager mock_file_input.return_value = (b"image data", "image/png") @@ -540,7 +507,6 @@ async def test_avatar_live_set_image_server_error(): mock_track = MagicMock() from decart.realtime.types import RealtimeConnectOptions - from decart.errors import DecartSDKError realtime_client = await RealtimeClient.connect( base_url=client.base_url, @@ -552,17 +518,331 @@ async def test_avatar_live_set_image_server_error(): ), ) - async def set_event(): - await asyncio.sleep(0.01) - image_set_event.set() - - asyncio.create_task(set_event()) - with pytest.raises(DecartSDKError) as exc_info: await realtime_client.set_image(b"test image") assert "Invalid image format" in str(exc_info.value) - mock_manager.unregister_image_set_wait.assert_called_once() + + +# Tests for set() method + + +@pytest.mark.asyncio +async def test_set_rejects_when_neither_prompt_nor_image(): + """Test set() raises when neither prompt nor image is provided""" + client = DecartClient(api_key="test-key") + + with ( + patch("decart.realtime.client.WebRTCManager") as mock_manager_class, + patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls, + ): + mock_manager = AsyncMock() + mock_manager.connect = AsyncMock(return_value=True) + mock_manager_class.return_value = mock_manager + + mock_session = MagicMock() + mock_session.closed = False + mock_session.close = AsyncMock() + mock_session_cls.return_value = mock_session + + mock_track = MagicMock() + + from decart.realtime.types import RealtimeConnectOptions + from decart.realtime.client import SetInput + from decart.errors import InvalidInputError + + realtime_client = await RealtimeClient.connect( + base_url=client.base_url, + api_key=client.api_key, + local_track=mock_track, + options=RealtimeConnectOptions( + model=models.realtime("mirage"), + on_remote_stream=lambda t: None, + ), + ) + + with pytest.raises(InvalidInputError, match="At least one of"): + await realtime_client.set(SetInput()) + + +@pytest.mark.asyncio +async def test_set_rejects_empty_prompt(): + """Test set() raises when prompt is empty string""" + client = DecartClient(api_key="test-key") + + with ( + patch("decart.realtime.client.WebRTCManager") as mock_manager_class, + patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls, + ): + mock_manager = AsyncMock() + mock_manager.connect = AsyncMock(return_value=True) + mock_manager_class.return_value = mock_manager + + mock_session = MagicMock() + mock_session.closed = False + mock_session.close = AsyncMock() + mock_session_cls.return_value = mock_session + + mock_track = MagicMock() + + from decart.realtime.types import RealtimeConnectOptions + from decart.realtime.client import SetInput + from decart.errors import InvalidInputError + + realtime_client = await RealtimeClient.connect( + base_url=client.base_url, + api_key=client.api_key, + local_track=mock_track, + options=RealtimeConnectOptions( + model=models.realtime("mirage"), + on_remote_stream=lambda t: None, + ), + ) + + with pytest.raises(InvalidInputError, match="Prompt cannot be empty"): + await realtime_client.set(SetInput(prompt="")) + + +@pytest.mark.asyncio +async def test_set_sends_prompt_only(): + """Test set() sends prompt-only via set_image with null image""" + client = DecartClient(api_key="test-key") + + with ( + patch("decart.realtime.client.WebRTCManager") as mock_manager_class, + patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls, + ): + mock_manager = AsyncMock() + mock_manager.connect = AsyncMock(return_value=True) + mock_manager.set_image = AsyncMock() + mock_manager_class.return_value = mock_manager + + mock_session = MagicMock() + mock_session.closed = False + mock_session.close = AsyncMock() + mock_session_cls.return_value = mock_session + + mock_track = MagicMock() + + from decart.realtime.types import RealtimeConnectOptions + from decart.realtime.client import SetInput + + realtime_client = await RealtimeClient.connect( + base_url=client.base_url, + api_key=client.api_key, + local_track=mock_track, + options=RealtimeConnectOptions( + model=models.realtime("mirage"), + on_remote_stream=lambda t: None, + ), + ) + + await realtime_client.set(SetInput(prompt="a cat")) + + mock_manager.set_image.assert_called_once_with( + None, + { + "prompt": "a cat", + "enhance": True, + "timeout": 30.0, + }, + ) + + +@pytest.mark.asyncio +async def test_set_sends_prompt_with_enhance(): + """Test set() sends prompt with enhance flag""" + client = DecartClient(api_key="test-key") + + with ( + patch("decart.realtime.client.WebRTCManager") as mock_manager_class, + patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls, + ): + mock_manager = AsyncMock() + mock_manager.connect = AsyncMock(return_value=True) + mock_manager.set_image = AsyncMock() + mock_manager_class.return_value = mock_manager + + mock_session = MagicMock() + mock_session.closed = False + mock_session.close = AsyncMock() + mock_session_cls.return_value = mock_session + + mock_track = MagicMock() + + from decart.realtime.types import RealtimeConnectOptions + from decart.realtime.client import SetInput + + realtime_client = await RealtimeClient.connect( + base_url=client.base_url, + api_key=client.api_key, + local_track=mock_track, + options=RealtimeConnectOptions( + model=models.realtime("mirage"), + on_remote_stream=lambda t: None, + ), + ) + + await realtime_client.set(SetInput(prompt="a cat", enhance=False)) + + mock_manager.set_image.assert_called_once_with( + None, + { + "prompt": "a cat", + "enhance": False, + "timeout": 30.0, + }, + ) + + +@pytest.mark.asyncio +async def test_set_sends_image_only(): + """Test set() sends image-only via set_image""" + client = DecartClient(api_key="test-key") + + with ( + patch("decart.realtime.client.WebRTCManager") as mock_manager_class, + patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls, + patch("decart.realtime.client._image_to_base64") as mock_image_to_base64, + ): + mock_manager = AsyncMock() + mock_manager.connect = AsyncMock(return_value=True) + mock_manager.set_image = AsyncMock() + mock_manager_class.return_value = mock_manager + + mock_session = MagicMock() + mock_session.closed = False + mock_session.close = AsyncMock() + mock_session_cls.return_value = mock_session + + mock_image_to_base64.return_value = "convertedbase64" + + mock_track = MagicMock() + + from decart.realtime.types import RealtimeConnectOptions + from decart.realtime.client import SetInput + + realtime_client = await RealtimeClient.connect( + base_url=client.base_url, + api_key=client.api_key, + local_track=mock_track, + options=RealtimeConnectOptions( + model=models.realtime("mirage"), + on_remote_stream=lambda t: None, + ), + ) + + await realtime_client.set(SetInput(image="rawbase64data")) + + mock_image_to_base64.assert_called_once_with("rawbase64data", mock_session) + mock_manager.set_image.assert_called_once_with( + "convertedbase64", + { + "prompt": None, + "enhance": True, + "timeout": 30.0, + }, + ) + + +@pytest.mark.asyncio +async def test_set_sends_prompt_and_image(): + """Test set() sends prompt and image together""" + client = DecartClient(api_key="test-key") + + with ( + patch("decart.realtime.client.WebRTCManager") as mock_manager_class, + patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls, + patch("decart.realtime.client._image_to_base64") as mock_image_to_base64, + ): + mock_manager = AsyncMock() + mock_manager.connect = AsyncMock(return_value=True) + mock_manager.set_image = AsyncMock() + mock_manager_class.return_value = mock_manager + + mock_session = MagicMock() + mock_session.closed = False + mock_session.close = AsyncMock() + mock_session_cls.return_value = mock_session + + mock_image_to_base64.return_value = "convertedbase64" + + mock_track = MagicMock() + + from decart.realtime.types import RealtimeConnectOptions + from decart.realtime.client import SetInput + + realtime_client = await RealtimeClient.connect( + base_url=client.base_url, + api_key=client.api_key, + local_track=mock_track, + options=RealtimeConnectOptions( + model=models.realtime("mirage"), + on_remote_stream=lambda t: None, + ), + ) + + await realtime_client.set(SetInput(prompt="a cat", enhance=False, image="rawbase64")) + + mock_manager.set_image.assert_called_once_with( + "convertedbase64", + { + "prompt": "a cat", + "enhance": False, + "timeout": 30.0, + }, + ) + + +@pytest.mark.asyncio +async def test_set_converts_bytes_image(): + """Test set() converts bytes image to base64""" + client = DecartClient(api_key="test-key") + + with ( + patch("decart.realtime.client.WebRTCManager") as mock_manager_class, + patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls, + patch("decart.realtime.client._image_to_base64") as mock_image_to_base64, + ): + mock_manager = AsyncMock() + mock_manager.connect = AsyncMock(return_value=True) + mock_manager.set_image = AsyncMock() + mock_manager_class.return_value = mock_manager + + mock_session = MagicMock() + mock_session.closed = False + mock_session.close = AsyncMock() + mock_session_cls.return_value = mock_session + + mock_image_to_base64.return_value = "blobbase64" + + mock_track = MagicMock() + + from decart.realtime.types import RealtimeConnectOptions + from decart.realtime.client import SetInput + + realtime_client = await RealtimeClient.connect( + base_url=client.base_url, + api_key=client.api_key, + local_track=mock_track, + options=RealtimeConnectOptions( + model=models.realtime("mirage"), + on_remote_stream=lambda t: None, + ), + ) + + test_bytes = b"test-image-data" + await realtime_client.set(SetInput(image=test_bytes)) + + mock_image_to_base64.assert_called_once_with(test_bytes, mock_session) + mock_manager.set_image.assert_called_once_with( + "blobbase64", + { + "prompt": None, + "enhance": True, + "timeout": 30.0, + }, + ) @pytest.mark.asyncio diff --git a/uv.lock b/uv.lock index 96ac2ab..72ea5ca 100644 --- a/uv.lock +++ b/uv.lock @@ -597,7 +597,7 @@ wheels = [ [[package]] name = "decart" -version = "0.0.17" +version = "0.0.18" source = { editable = "." } dependencies = [ { name = "aiofiles" },