From 3d7ef2b874d80e0950dfafe9078da49c46a1a10d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Wed, 22 Oct 2025 10:28:48 +0200 Subject: [PATCH] feat: agent structure and implementation new architecture with unit tests ref: N25B-205 --- .vscode/settings.json | 7 + main.py | 219 -------- pyproject.toml | 4 + .../agents/ri_command_agent.py | 60 +++ .../agents/ri_communication_agent.py | 138 +++++ src/control_backend/core/config.py | 14 +- src/control_backend/main.py | 11 +- test/unit/test_ri_commands_agent.py | 84 +++ test/unit/test_ri_communication_agent.py | 498 ++++++++++++++++++ uv.lock | 141 +++++ 10 files changed, 953 insertions(+), 223 deletions(-) create mode 100644 .vscode/settings.json delete mode 100644 main.py create mode 100644 src/control_backend/agents/ri_command_agent.py create mode 100644 src/control_backend/agents/ri_communication_agent.py create mode 100644 test/unit/test_ri_commands_agent.py create mode 100644 test/unit/test_ri_communication_agent.py diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b2b8866 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.testing.pytestArgs": [ + "test" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/main.py b/main.py deleted file mode 100644 index 874dfc4..0000000 --- a/main.py +++ /dev/null @@ -1,219 +0,0 @@ -import asyncio -from contextlib import asynccontextmanager -from fastapi import FastAPI, Request -from fastapi.middleware.cors import CORSMiddleware - -import zmq -import zmq.asyncio -from fastapi.responses import StreamingResponse -from pydantic import BaseModel -import datetime -import json; - - -zmq_state = {} # Contains our sockets and context - -# Use of Pydantic class for automatic request validation in FastAPI -class Message(BaseModel): - message: str - -@asynccontextmanager -async def lifespan(app: FastAPI): - context = zmq.Context() - - # Set up sub and pub - zmq_state["context"] = zmq.asyncio.Context() - - subport = 5555 - pubport = 5556 - asyncio.create_task(zmq_subscriber(subport, app)) - asyncio.create_task(zmq_publisher(pubport, app)) - print("Set up both sub and pub") - - app.state.sse_queue = asyncio.Queue() # Messages to send to UI - app.state.ri_queue = asyncio.Queue() # Messages to send to RI - - # Handle pings - app.state.received_ping = False - app.state.connected = False - app.state.connected_id = "" - - yield - - -async def zmq_subscriber(port, app): - """ - Set up the zmq subscriber to listen to the port given - """ - print(f"Setting up ZMQ subscriber on port {port}") - sub = zmq_state["context"].socket(zmq.SUB) - sub.connect(f"tcp://localhost:{port}") - sub.setsockopt_string(zmq.SUBSCRIBE, u"") - zmq_state["subsocket"] = sub - - print(f"Subscriber connected to localhost:{port}, waiting for messages...") - - while True: - print(f"Listening for message from zmq sub on port {port}.") - msg = await sub.recv_string() - print(f"Received from SUB: {msg}") - - # We got a message, let's see what we want to do with it. - await process_message_received(msg, app) - -async def zmq_publisher(port, app): - """ - Set up the zmq publisher to send to the port given - """ - queue = app.state.ri_queue - pub = zmq_state["context"].socket(zmq.PUB) - pub.bind(f"tcp://*:{port}") - zmq_state["pubsocket"] = pub - while True: - if not queue.empty(): - # send different message to RI - return - if app.state.connected == True: - # (In case we have nothing else to send:) - # Let's ping our RI to see if they're still listening! - app.state.received_ping = False - zmq_state["pubsocket"].send_string("ping") - await asyncio.sleep(5) - # Let's see if we haven't returned a ping in the last 5 seconds... - if not app.state.received_ping == True: - # Let's send our UI a message the robot disappeared. - dataToSend = { - "event": "robot_disconnected", - "id": app.state.connected_id - } - # Reset connection details - app.state.connected = False - app.state.connectedID = "" - await put_message_in_ui_queue(dataToSend, app) - await asyncio.sleep(1) - - -async def put_message_in_ui_queue(data, app): - queue = app.state.sse_queue - await queue.put(data) - -async def put_message_in_ri_queue(data, app): - queue = app.state.ri_queue - await queue.put(data) - -async def process_message_received(msg, app): - """ - Process a raw received message to handle it correctly. - """ - queue = app.state.sse_queue - # string handling - if type(msg) is str: - try: - print("converting received data into json.") - data = json.loads(msg) - - print("converted data: ", data) - - # Connection event - if (data['event'] == 'robot_connected'): - print("robot connection event received.") - if not data["id"]: - return - - # Let our app know we're connected >:) - app.state.connected_id = data["id"] - app.state.connected = True - app.state.received_ping = True - - # Send data to UI - name = data.get("name", "no name") - port = data.get("port", "no port") - dataToSend = { - "event": "robot_connected", - "id": data["id"], - "name": name, - "port": port - } - await queue.put(dataToSend) - - # Disconnection event - if (data['event'] == 'robot_disconnected'): - if not data["id"]: - return - name = data.get("name", "no name") - port = data.get("port", "no port") - - dataToSend = { - "event": "robot_disconnected", - "id": data["id"], - "name": name, - "port": port - } - - await queue.put(dataToSend) - - # Ping event - if (data['event'] == 'ping'): - print("ping received") - if not data["id"]: - print("no id given in ping event.") - return - - # TODO: You can add some logic here if the ID doens't match (so we switched robot at the same frame lol) - app.state.received_ping = True - return - - except: - print("message received from RI, however, not a str or json, or another error has occured.") - return - # do shit - - -app = FastAPI(lifespan=lifespan) - -# This middleware allows other origins to communicate with us -app.add_middleware( - CORSMiddleware, # https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS - allow_origins=["http://localhost:5173"], # address of our UI application - allow_methods=["*"], # GET, POST, etc. -) - -# Endpoint to receive messages from the UI -@app.post("/message") -async def receive_message(message: Message): - """ - Receives a message from the UI and prints it to the console. - """ - print(f"Received message: {message}") - return { "status": "Message received" } - -# Endpoint for Server-Sent Events (SSE) -@app.get("/sse") -async def sse_endpoint(request: Request): - """ - Endpoint for Server-Sent Events. - """ - - async def event_generator(): - while True: - # If connection to client closes, stop sending events - if await request.is_disconnected(): - break - - # Let's check if we have to send any messages from our sse queue - queue = app.state.sse_queue - if not queue.empty(): - print("message queue not empty, fetching data.") - data = await queue.get() - data_json = json.dumps(data) - print(f"queue not empty. yielding msg to event_generator, msg: {data_json}\n\n") - yield f"data: {data_json}\n\n" - await asyncio.sleep(1) - - else: - # Send message containing current time every second - current_time = datetime.datetime.now().strftime("%H:%M:%S") - yield f"data: Server time: {current_time}\n\n" # \n\n is needed to separate events (SSE is text-based) - await asyncio.sleep(1) - - return StreamingResponse(event_generator(), media_type="text/event-stream") # media_type specifies that this connection is for event streams diff --git a/pyproject.toml b/pyproject.toml index d0a617f..7d1330b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,10 @@ dependencies = [ "pyaudio>=0.2.14", "pydantic>=2.12.0", "pydantic-settings>=2.11.0", + "pytest>=8.4.2", + "pytest-asyncio>=1.2.0", + "pytest-cov>=7.0.0", + "pytest-mock>=3.15.1", "pyzmq>=27.1.0", "silero-vad>=6.0.0", "spade>=4.1.0", diff --git a/src/control_backend/agents/ri_command_agent.py b/src/control_backend/agents/ri_command_agent.py new file mode 100644 index 0000000..02de887 --- /dev/null +++ b/src/control_backend/agents/ri_command_agent.py @@ -0,0 +1,60 @@ +import json +import logging +from spade.agent import Agent +from spade.behaviour import CyclicBehaviour +import zmq + +from control_backend.core.config import settings +from control_backend.core.zmq_context import context +from control_backend.schemas.message import Message + +logger = logging.getLogger(__name__) + +class RICommandAgent(Agent): + subsocket: zmq.Socket + pubsocket: zmq.Socket + address = "" + bind = False + + def __init__(self, jid: str, password: str, port: int = 5222, verify_security: bool = False, address = "tcp://localhost:0000", bind = False): + super().__init__(jid, password, port, verify_security) + self.address = address + self.bind = bind + + class SendCommandsBehaviour(CyclicBehaviour): + async def run(self): + assert self.agent is not None + # Get a message internally (with topic command) + topic, body = await self.agent.subsocket.recv_multipart() + + # Try to get body + try: + message_json = json.loads(body.decode("utf-8")) + message = Message.model_validate(message_json) + logger.info("Received message \"%s\"", message.message) + + # Send to the robot. + await self.agent.pubsocket.send_json(message) + except Exception as e: + logger.error("Error processing message: %s", e) + + async def setup(self): + logger.info("Setting up %s", self.jid) + + # To the robot + self.pubsocket = context.socket(zmq.PUB) + if self.bind: + self.pubsocket.bind(self.address) + else : + self.pubsocket.connect(self.address) + + # Receive internal topics regarding commands + self.subsocket = context.socket(zmq.SUB) + self.subsocket.connect(settings.zmq_settings.internal_comm_address) + self.subsocket.setsockopt(zmq.SUBSCRIBE, b"command") + + # Add behaviour to our agent + commands_behaviour = self.SendCommandsBehaviour() + self.add_behaviour(commands_behaviour) + + logger.info("Finished setting up %s", self.jid) diff --git a/src/control_backend/agents/ri_communication_agent.py b/src/control_backend/agents/ri_communication_agent.py new file mode 100644 index 0000000..0c63cc5 --- /dev/null +++ b/src/control_backend/agents/ri_communication_agent.py @@ -0,0 +1,138 @@ +import asyncio +import json +import logging +from spade.agent import Agent +from spade.behaviour import CyclicBehaviour +import zmq + +from control_backend.core.config import settings +from control_backend.core.zmq_context import context +from control_backend.schemas.message import Message +from control_backend.agents.ri_command_agent import RICommandAgent + +logger = logging.getLogger(__name__) + +class RICommunicationAgent(Agent): + req_socket: zmq.Socket + _address = "" + _bind = True + + def __init__(self, jid: str, password: str, port: int = 5222, verify_security: bool = False, address = "tcp://localhost:0000", bind = False): + super().__init__(jid, password, port, verify_security) + self._address = address + self._bind = bind + + class ListenBehaviour(CyclicBehaviour): + async def run(self): + assert self.agent is not None + + # We need to listen and sent pings. + message = {"endpoint": "ping", "data": {"id": "e.g. some reference id"}} + await self.agent.req_socket.send_json(message) + + # Wait up to three seconds for a reply:) + try: + message = await asyncio.wait_for( + self.agent.req_socket.recv_json(), + timeout=3.0) + + # We didnt get a reply :( + except asyncio.TimeoutError as e: + logger.info("No ping retrieved in 3 seconds, killing myself.") + self.kill() + + # message = Message.model_validate(message) + logger.info("Received message \"%s\"", message) + if "endpoint" not in message: + logger.error("No received endpoint in message, excepted ping endpoint.") + return + + # See what endpoint we received + match message["endpoint"]: + case "ping": + await asyncio.sleep(1) + case _: + logger.info("Received message with topic different than ping, while ping expected.") + + + async def setup(self, max_retries: int = 5): + logger.info("Setting up %s", self.jid) + retries = 0 + + # Let's try a certain amount of times before failing connection + while retries < max_retries: + # Bind request socket + self.req_socket = context.socket(zmq.REQ) + if self._bind: + self.req_socket.bind(self._address) + else: + self.req_socket.connect(self._address) + + # Send our message and receive one back:) + message = {"endpoint": "negotiate/ports", "data": None} + await self.req_socket.send_json(message) + + try: + received_message = await asyncio.wait_for(self.req_socket.recv_json(), timeout=20.0) + + except asyncio.TimeoutError: + logger.warning("No connection established in 20 seconds (attempt %d/%d)", retries + 1, max_retries) + retries += 1 + continue + + except Exception as e: + logger.error("Unexpected error during negotiation: %s", e) + retries += 1 + continue + + # Validate endpoint + endpoint = received_message.get("endpoint") + if endpoint != "negotiate/ports": + # TODO: Should this send a message back? + logger.error("Invalid endpoint '%s' received (attempt %d/%d)", endpoint, retries + 1, max_retries) + retries += 1 + continue + + # At this point, we have a valid response + try: + for port_data in received_message["data"]: + id = port_data["id"] + port = port_data["port"] + bind = port_data["bind"] + addr = f"tcp://localhost:{port}" + + match id: + case "main": + if addr != self._address: + if not bind: + self.req_socket.connect(addr) + else: + self.req_socket.bind(addr) + case "actuation": + ri_commands_agent = RICommandAgent( + settings.agent_settings.ri_command_agent_name + '@' + settings.agent_settings.host, + settings.agent_settings.ri_command_agent_name, + address=addr, + bind=bind ) + await ri_commands_agent.start() + case _: + logger.warning("Unhandled negotiation id: %s", id) + + except Exception as e: + logger.error("Error unpacking negotiation data: %s", e) + retries += 1 + continue + + # setup succeeded + break + + else: + logger.error("Failed to set up RICommunicationAgent after %d retries", max_retries) + return + + # Set up ping behaviour + listen_behaviour = self.ListenBehaviour() + self.add_behaviour(listen_behaviour) + logger.info("Finished setting up %s", self.jid) + + diff --git a/src/control_backend/core/config.py b/src/control_backend/core/config.py index fca21b3..43fdedc 100644 --- a/src/control_backend/core/config.py +++ b/src/control_backend/core/config.py @@ -1,15 +1,27 @@ +from re import L from pydantic import BaseModel from pydantic_settings import BaseSettings, SettingsConfigDict class ZMQSettings(BaseModel): internal_comm_address: str = "tcp://localhost:5560" +class AgentSettings(BaseModel): + host: str = "localhost" + bdi_core_agent_name: str = "bdi_core" + belief_collector_agent_name: str = "belief_collector" + test_agent_name: str = "test_agent" + + ri_communication_agent_name: str = "ri_communication_agent" + ri_command_agent_name: str = "ri_command_agent" + class Settings(BaseSettings): app_title: str = "PepperPlus" - + ui_url: str = "http://localhost:5173" zmq_settings: ZMQSettings = ZMQSettings() + + agent_settings: AgentSettings = AgentSettings() model_config = SettingsConfigDict(env_file=".env") diff --git a/src/control_backend/main.py b/src/control_backend/main.py index cd4d3fa..bb0f8d7 100644 --- a/src/control_backend/main.py +++ b/src/control_backend/main.py @@ -7,6 +7,7 @@ import zmq # Internal imports from control_backend.agents.test_agent import TestAgent +from control_backend.agents.ri_communication_agent import RICommunicationAgent from control_backend.api.v1.router import api_router from control_backend.core.config import settings from control_backend.core.zmq_context import context @@ -26,9 +27,13 @@ async def lifespan(app: FastAPI): logger.info("Internal publishing socket bound to %s", internal_comm_socket) # Initiate agents - test_agent = TestAgent("test_agent@localhost", "test_agent") - await test_agent.start() - + logger.info(settings.agent_settings.ri_communication_agent_name + '@' + settings.agent_settings.host) + logger.info(settings.agent_settings.ri_communication_agent_name) + ri_communication_agent = RICommunicationAgent(settings.agent_settings.ri_communication_agent_name + '@' + settings.agent_settings.host, + settings.agent_settings.ri_communication_agent_name, + address="tcp://*:5555", bind=True) + await ri_communication_agent.start() + yield logger.info("%s shutting down.", app.title) diff --git a/test/unit/test_ri_commands_agent.py b/test/unit/test_ri_commands_agent.py new file mode 100644 index 0000000..fc5f4aa --- /dev/null +++ b/test/unit/test_ri_commands_agent.py @@ -0,0 +1,84 @@ +import asyncio +import zmq +import json +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from control_backend.agents.ri_command_agent import RICommandAgent +from control_backend.schemas.message import Message + +@pytest.mark.asyncio +async def test_setup_bind(monkeypatch): + """Test setup with bind=True""" + fake_socket = MagicMock() + monkeypatch.setattr("control_backend.agents.ri_command_agent.context.socket", lambda _: fake_socket) + + agent = RICommandAgent("test@server", "password", address="tcp://localhost:5555", bind=True) + monkeypatch.setattr("control_backend.agents.ri_command_agent.settings", MagicMock(zmq_settings=MagicMock(internal_comm_address="tcp://internal:1234"))) + + await agent.setup() + + # Ensure PUB socket bound + fake_socket.bind.assert_any_call("tcp://localhost:5555") + # Ensure SUB socket connected to internal address and subscribed + fake_socket.connect.assert_any_call("tcp://internal:1234") + fake_socket.setsockopt.assert_any_call(zmq.SUBSCRIBE, b"command") + + # Ensure behaviour attached + assert any(isinstance(b, agent.SendCommandsBehaviour) for b in agent.behaviours) + +@pytest.mark.asyncio +async def test_setup_connect(monkeypatch): + """Test setup with bind=False""" + fake_socket = MagicMock() + monkeypatch.setattr("control_backend.agents.ri_command_agent.context.socket", lambda _: fake_socket) + + agent = RICommandAgent("test@server", "password", address="tcp://localhost:5555", bind=False) + monkeypatch.setattr("control_backend.agents.ri_command_agent.settings", MagicMock(zmq_settings=MagicMock(internal_comm_address="tcp://internal:1234"))) + + await agent.setup() + + # Ensure PUB socket connected + fake_socket.connect.assert_any_call("tcp://localhost:5555") + +@pytest.mark.asyncio +async def test_send_commands_behaviour_valid_message(caplog): + """Test behaviour with valid JSON message""" + fake_socket = AsyncMock() + message_dict = {"message": "hello"} + fake_socket.recv_multipart = AsyncMock(return_value=(b"command", json.dumps(message_dict).encode("utf-8"))) + fake_socket.send_json = AsyncMock() + + agent = RICommandAgent("test@server", "password") + agent.subsocket = fake_socket + agent.pubsocket = fake_socket + + behaviour = agent.SendCommandsBehaviour() + behaviour.agent = agent + + with caplog.at_level("INFO"): + await behaviour.run() + + fake_socket.recv_multipart.assert_awaited() + fake_socket.send_json.assert_awaited() + assert "Received message" in caplog.text + +@pytest.mark.asyncio +async def test_send_commands_behaviour_invalid_message(caplog): + """Test behaviour with invalid JSON message triggers error logging""" + fake_socket = AsyncMock() + fake_socket.recv_multipart = AsyncMock(return_value=(b"command", b"{invalid_json}")) + fake_socket.send_json = AsyncMock() + + agent = RICommandAgent("test@server", "password") + agent.subsocket = fake_socket + agent.pubsocket = fake_socket + + behaviour = agent.SendCommandsBehaviour() + behaviour.agent = agent + + with caplog.at_level("ERROR"): + await behaviour.run() + + fake_socket.recv_multipart.assert_awaited() + fake_socket.send_json.assert_not_awaited() + assert "Error processing message" in caplog.text diff --git a/test/unit/test_ri_communication_agent.py b/test/unit/test_ri_communication_agent.py new file mode 100644 index 0000000..9cc14f0 --- /dev/null +++ b/test/unit/test_ri_communication_agent.py @@ -0,0 +1,498 @@ +import asyncio +import pytest +from unittest.mock import AsyncMock, MagicMock, patch, ANY +from control_backend.agents.ri_communication_agent import RICommunicationAgent + +def fake_json_correct_negototiate_1(): + return AsyncMock(return_value={ + "endpoint": "negotiate/ports", + "data": [ + {"id": "main", "port": 5555, "bind": False}, + {"id": "actuation", "port": 5556, "bind": True}, + ]}) + +def fake_json_correct_negototiate_2(): + return AsyncMock(return_value={ + "endpoint": "negotiate/ports", + "data": [ + {"id": "main", "port": 5555, "bind": False}, + {"id": "actuation", "port": 5557, "bind": True}, + ]}) + +def fake_json_correct_negototiate_3(): + return AsyncMock(return_value={ + "endpoint": "negotiate/ports", + "data": [ + {"id": "main", "port": 5555, "bind": True}, + {"id": "actuation", "port": 5557, "bind": True}, + ]}) + +def fake_json_correct_negototiate_4(): + # Different port, do bind + return AsyncMock(return_value={ + "endpoint": "negotiate/ports", + "data": [ + {"id": "main", "port": 4555, "bind": True}, + {"id": "actuation", "port": 5557, "bind": True}, + ]}) + +def fake_json_correct_negototiate_5(): + # Different port, dont bind. + return AsyncMock(return_value={ + "endpoint": "negotiate/ports", + "data": [ + {"id": "main", "port": 4555, "bind": False}, + {"id": "actuation", "port": 5557, "bind": True}, + ]}) + +def fake_json_wrong_negototiate_1(): + return AsyncMock(return_value={ + "endpoint": "ping", + "data": ""}) + +def fake_json_invalid_id_negototiate(): + return AsyncMock(return_value={ + "endpoint": "negotiate/ports", + "data": [ + {"id": "banana", "port": 4555, "bind": False}, + {"id": "tomato", "port": 5557, "bind": True}, + ]}) + +@pytest.mark.asyncio +async def test_setup_creates_socket_and_negotiate_1(monkeypatch): + """ + Test the setup of the communication agent + """ + # --- Arrange --- + fake_socket = MagicMock() + fake_socket.send_json = AsyncMock() + fake_socket.recv_json = fake_json_correct_negototiate_1() + + # Mock context.socket to return our fake socket + monkeypatch.setattr("control_backend.agents.ri_communication_agent.context.socket", lambda _: fake_socket) + + # Mock RICommandAgent agent startup + with patch("control_backend.agents.ri_communication_agent.RICommandAgent", autospec=True) as MockCommandAgent: + fake_agent_instance = MockCommandAgent.return_value + fake_agent_instance.start = AsyncMock() + + # --- Act --- + agent = RICommunicationAgent("test@server", "password", address="tcp://localhost:5555", bind=False) + await agent.setup() + + # --- Assert --- + fake_socket.connect.assert_any_call("tcp://localhost:5555") + fake_socket.send_json.assert_any_call({"endpoint": "negotiate/ports", "data": None}) + fake_socket.recv_json.assert_awaited() + fake_agent_instance.start.assert_awaited() + MockCommandAgent.assert_called_once_with( + ANY, # Server Name + ANY, # Server Password + address="tcp://localhost:5556", # derived from the 'port' value in negotiation + bind=True + ) + # Ensure the agent attached a ListenBehaviour + assert any(isinstance(b, agent.ListenBehaviour) for b in agent.behaviours) + +@pytest.mark.asyncio +async def test_setup_creates_socket_and_negotiate_2(monkeypatch): + """ + Test the setup of the communication agent + """ + # --- Arrange --- + fake_socket = MagicMock() + fake_socket.send_json = AsyncMock() + fake_socket.recv_json = fake_json_correct_negototiate_2() + + # Mock context.socket to return our fake socket + monkeypatch.setattr("control_backend.agents.ri_communication_agent.context.socket", lambda _: fake_socket) + + # Mock RICommandAgent agent startup + with patch("control_backend.agents.ri_communication_agent.RICommandAgent", autospec=True) as MockCommandAgent: + fake_agent_instance = MockCommandAgent.return_value + fake_agent_instance.start = AsyncMock() + + # --- Act --- + agent = RICommunicationAgent("test@server", "password", address="tcp://localhost:5555", bind=False) + await agent.setup() + + # --- Assert --- + fake_socket.connect.assert_any_call("tcp://localhost:5555") + fake_socket.send_json.assert_any_call({"endpoint": "negotiate/ports", "data": None}) + fake_socket.recv_json.assert_awaited() + fake_agent_instance.start.assert_awaited() + MockCommandAgent.assert_called_once_with( + ANY, # Server Name + ANY, # Server Password + address="tcp://localhost:5557", # derived from the 'port' value in negotiation + bind=True + ) + # Ensure the agent attached a ListenBehaviour + assert any(isinstance(b, agent.ListenBehaviour) for b in agent.behaviours) + +@pytest.mark.asyncio +async def test_setup_creates_socket_and_negotiate_3(monkeypatch, caplog): + """ + Test the functionality of setup with incorrect negotiation message + """ + # --- Arrange --- + fake_socket = MagicMock() + fake_socket.send_json = AsyncMock() + fake_socket.recv_json = fake_json_wrong_negototiate_1() + + # Mock context.socket to return our fake socket + monkeypatch.setattr("control_backend.agents.ri_communication_agent.context.socket", lambda _: fake_socket) + + # Mock RICommandAgent agent startup + + + # We are sending wrong negotiation info to the communication agent, so we should retry and expect a + # better response, within a limited time. + with patch("control_backend.agents.ri_communication_agent.RICommandAgent", autospec=True) as MockCommandAgent: + fake_agent_instance = MockCommandAgent.return_value + fake_agent_instance.start = AsyncMock() + + # --- Act --- + with caplog.at_level("ERROR"): + agent = RICommunicationAgent("test@server", "password", address="tcp://localhost:5555", bind=False) + await agent.setup(max_retries=1) + + # --- Assert --- + fake_socket.connect.assert_any_call("tcp://localhost:5555") + fake_socket.recv_json.assert_awaited() + + # Since it failed, there should not be any command agent. + fake_agent_instance.start.assert_not_awaited() + assert "Failed to set up RICommunicationAgent" in caplog.text + + # Ensure the agent did not attach a ListenBehaviour + assert not any(isinstance(b, agent.ListenBehaviour) for b in agent.behaviours) + +@pytest.mark.asyncio +async def test_setup_creates_socket_and_negotiate_4(monkeypatch): + """ + Test the setup of the communication agent with different bind value + """ + # --- Arrange --- + fake_socket = MagicMock() + fake_socket.send_json = AsyncMock() + fake_socket.recv_json = fake_json_correct_negototiate_3() + + # Mock context.socket to return our fake socket + monkeypatch.setattr("control_backend.agents.ri_communication_agent.context.socket", lambda _: fake_socket) + + # Mock RICommandAgent agent startup + with patch("control_backend.agents.ri_communication_agent.RICommandAgent", autospec=True) as MockCommandAgent: + fake_agent_instance = MockCommandAgent.return_value + fake_agent_instance.start = AsyncMock() + + # --- Act --- + agent = RICommunicationAgent("test@server", "password", address="tcp://localhost:5555", bind=True) + await agent.setup() + + # --- Assert --- + fake_socket.bind.assert_any_call("tcp://localhost:5555") + fake_socket.send_json.assert_any_call({"endpoint": "negotiate/ports", "data": None}) + fake_socket.recv_json.assert_awaited() + fake_agent_instance.start.assert_awaited() + MockCommandAgent.assert_called_once_with( + ANY, # Server Name + ANY, # Server Password + address="tcp://localhost:5557", # derived from the 'port' value in negotiation + bind=True + ) + # Ensure the agent attached a ListenBehaviour + assert any(isinstance(b, agent.ListenBehaviour) for b in agent.behaviours) + +@pytest.mark.asyncio +async def test_setup_creates_socket_and_negotiate_5(monkeypatch): + """ + Test the setup of the communication agent + """ + # --- Arrange --- + fake_socket = MagicMock() + fake_socket.send_json = AsyncMock() + fake_socket.recv_json = fake_json_correct_negototiate_4() + + # Mock context.socket to return our fake socket + monkeypatch.setattr("control_backend.agents.ri_communication_agent.context.socket", lambda _: fake_socket) + + # Mock RICommandAgent agent startup + with patch("control_backend.agents.ri_communication_agent.RICommandAgent", autospec=True) as MockCommandAgent: + fake_agent_instance = MockCommandAgent.return_value + fake_agent_instance.start = AsyncMock() + + # --- Act --- + agent = RICommunicationAgent("test@server", "password", address="tcp://localhost:5555", bind=False) + await agent.setup() + + # --- Assert --- + fake_socket.connect.assert_any_call("tcp://localhost:5555") + fake_socket.send_json.assert_any_call({"endpoint": "negotiate/ports", "data": None}) + fake_socket.recv_json.assert_awaited() + fake_agent_instance.start.assert_awaited() + MockCommandAgent.assert_called_once_with( + ANY, # Server Name + ANY, # Server Password + address="tcp://localhost:5557", # derived from the 'port' value in negotiation + bind=True + ) + # Ensure the agent attached a ListenBehaviour + assert any(isinstance(b, agent.ListenBehaviour) for b in agent.behaviours) + +@pytest.mark.asyncio +async def test_setup_creates_socket_and_negotiate_6(monkeypatch): + """ + Test the setup of the communication agent + """ + # --- Arrange --- + fake_socket = MagicMock() + fake_socket.send_json = AsyncMock() + fake_socket.recv_json = fake_json_correct_negototiate_5() + + # Mock context.socket to return our fake socket + monkeypatch.setattr("control_backend.agents.ri_communication_agent.context.socket", lambda _: fake_socket) + + # Mock RICommandAgent agent startup + with patch("control_backend.agents.ri_communication_agent.RICommandAgent", autospec=True) as MockCommandAgent: + fake_agent_instance = MockCommandAgent.return_value + fake_agent_instance.start = AsyncMock() + + # --- Act --- + agent = RICommunicationAgent("test@server", "password", address="tcp://localhost:5555", bind=False) + await agent.setup() + + # --- Assert --- + fake_socket.connect.assert_any_call("tcp://localhost:5555") + fake_socket.send_json.assert_any_call({"endpoint": "negotiate/ports", "data": None}) + fake_socket.recv_json.assert_awaited() + fake_agent_instance.start.assert_awaited() + MockCommandAgent.assert_called_once_with( + ANY, # Server Name + ANY, # Server Password + address="tcp://localhost:5557", # derived from the 'port' value in negotiation + bind=True + ) + # Ensure the agent attached a ListenBehaviour + assert any(isinstance(b, agent.ListenBehaviour) for b in agent.behaviours) + +@pytest.mark.asyncio +async def test_setup_creates_socket_and_negotiate_7(monkeypatch, caplog): + """ + Test the functionality of setup with incorrect id + """ + # --- Arrange --- + fake_socket = MagicMock() + fake_socket.send_json = AsyncMock() + fake_socket.recv_json = fake_json_invalid_id_negototiate() + + # Mock context.socket to return our fake socket + monkeypatch.setattr("control_backend.agents.ri_communication_agent.context.socket", lambda _: fake_socket) + + # Mock RICommandAgent agent startup + + + # We are sending wrong negotiation info to the communication agent, so we should retry and expect a + # better response, within a limited time. + with patch("control_backend.agents.ri_communication_agent.RICommandAgent", autospec=True) as MockCommandAgent: + fake_agent_instance = MockCommandAgent.return_value + fake_agent_instance.start = AsyncMock() + + # --- Act --- + with caplog.at_level("WARNING"): + agent = RICommunicationAgent("test@server", "password", address="tcp://localhost:5555", bind=False) + await agent.setup(max_retries=1) + + # --- Assert --- + fake_socket.connect.assert_any_call("tcp://localhost:5555") + fake_socket.recv_json.assert_awaited() + + # Since it failed, there should not be any command agent. + fake_agent_instance.start.assert_not_awaited() + assert "Unhandled negotiation id:" in caplog.text + + +@pytest.mark.asyncio +async def test_setup_creates_socket_and_negotiate_timeout(monkeypatch, caplog): + """ + Test the functionality of setup with incorrect negotiation message + """ + # --- Arrange --- + fake_socket = MagicMock() + fake_socket.send_json = AsyncMock() + fake_socket.recv_json = AsyncMock(side_effect=asyncio.TimeoutError) + + # Mock context.socket to return our fake socket + monkeypatch.setattr("control_backend.agents.ri_communication_agent.context.socket", lambda _: fake_socket) + + with patch("control_backend.agents.ri_communication_agent.RICommandAgent", autospec=True) as MockCommandAgent: + fake_agent_instance = MockCommandAgent.return_value + fake_agent_instance.start = AsyncMock() + + # --- Act --- + with caplog.at_level("WARNING"): + agent = RICommunicationAgent("test@server", "password", address="tcp://localhost:5555", bind=False) + await agent.setup(max_retries=1) + + # --- Assert --- + fake_socket.connect.assert_any_call("tcp://localhost:5555") + + # Since it failed, there should not be any command agent. + fake_agent_instance.start.assert_not_awaited() + assert "No connection established in 20 seconds" in caplog.text + + # Ensure the agent did not attach a ListenBehaviour + assert not any(isinstance(b, agent.ListenBehaviour) for b in agent.behaviours) + +@pytest.mark.asyncio +async def test_listen_behaviour_ping_correct(caplog): + fake_socket = AsyncMock() + fake_socket.send_json = AsyncMock() + fake_socket.recv_json = AsyncMock(return_value={"endpoint": "ping", "data": {}}) + + # TODO: Integration test between actual server and password needed for spade agents + agent = RICommunicationAgent("test@server", "password") + agent.req_socket = fake_socket + + behaviour = agent.ListenBehaviour() + agent.add_behaviour(behaviour) + + # Run once (CyclicBehaviour normally loops) + with caplog.at_level("INFO"): + await behaviour.run() + + fake_socket.send_json.assert_awaited() + fake_socket.recv_json.assert_awaited() + assert "Received message" in caplog.text + +@pytest.mark.asyncio +async def test_listen_behaviour_ping_wrong_endpoint(caplog): + """ + Test if our listen behaviour can work with wrong messages (wrong endpoint) + """ + fake_socket = AsyncMock() + fake_socket.send_json = AsyncMock() + + # This is a message for the wrong endpoint >:( + fake_socket.recv_json = AsyncMock(return_value={ + "endpoint": "negotiate/ports", + "data": [ + {"id": "main", "port": 5555, "bind": False}, + {"id": "actuation", "port": 5556, "bind": True}, + ]}) + + agent = RICommunicationAgent("test@server", "password") + agent.req_socket = fake_socket + + behaviour = agent.ListenBehaviour() + agent.add_behaviour(behaviour) + + # Run once (CyclicBehaviour normally loops) + with caplog.at_level("INFO"): + await behaviour.run() + + + assert "Received message with topic different than ping, while ping expected." in caplog.text + fake_socket.send_json.assert_awaited() + fake_socket.recv_json.assert_awaited() + +@pytest.mark.asyncio +async def test_listen_behaviour_timeout(caplog): + fake_socket = AsyncMock() + fake_socket.send_json = AsyncMock() + # recv_json will never resolve, simulate timeout + fake_socket.recv_json = AsyncMock(side_effect=asyncio.TimeoutError) + + agent = RICommunicationAgent("test@server", "password") + agent.req_socket = fake_socket + + behaviour = agent.ListenBehaviour() + agent.add_behaviour(behaviour) + + with caplog.at_level("INFO"): + await behaviour.run() + + assert "No ping retrieved in 3 seconds" in caplog.text + +@pytest.mark.asyncio +async def test_listen_behaviour_ping_no_endpoint(caplog): + """ + Test if our listen behaviour can work with wrong messages (wrong endpoint) + """ + fake_socket = AsyncMock() + fake_socket.send_json = AsyncMock() + + # This is a message without endpoint >:( + fake_socket.recv_json = AsyncMock(return_value={ + "data": "I dont have an endpoint >:)", + }) + + agent = RICommunicationAgent("test@server", "password") + agent.req_socket = fake_socket + + behaviour = agent.ListenBehaviour() + agent.add_behaviour(behaviour) + + # Run once (CyclicBehaviour normally loops) + with caplog.at_level("ERROR"): + await behaviour.run() + + assert "No received endpoint in message, excepted ping endpoint." in caplog.text + fake_socket.send_json.assert_awaited() + fake_socket.recv_json.assert_awaited() + +@pytest.mark.asyncio +async def test_setup_unexpected_exception(monkeypatch, caplog): + fake_socket = MagicMock() + fake_socket.send_json = AsyncMock() + # Simulate unexpected exception during recv_json() + fake_socket.recv_json = AsyncMock(side_effect=Exception("boom!")) + + monkeypatch.setattr( + "control_backend.agents.ri_communication_agent.context.socket", + lambda _: fake_socket + ) + + agent = RICommunicationAgent("test@server", "password", address="tcp://localhost:5555", bind=False) + + with caplog.at_level("ERROR"): + await agent.setup(max_retries=1) + + # Ensure that the error was logged + assert "Unexpected error during negotiation: boom!" in caplog.text + +@pytest.mark.asyncio +async def test_setup_unpacking_exception(monkeypatch, caplog): + # --- Arrange --- + fake_socket = MagicMock() + fake_socket.send_json = AsyncMock() + + # Make recv_json return malformed negotiation data to trigger unpacking exception + malformed_data = {"endpoint": "negotiate/ports", "data": [ {"id": "main"} ]} # missing 'port' and 'bind' + fake_socket.recv_json = AsyncMock(return_value=malformed_data) + + # Patch context.socket + monkeypatch.setattr( + "control_backend.agents.ri_communication_agent.context.socket", + lambda _: fake_socket + ) + + # Patch RICommandAgent so it won't actually start + with patch("control_backend.agents.ri_communication_agent.RICommandAgent", autospec=True) as MockCommandAgent: + fake_agent_instance = MockCommandAgent.return_value + fake_agent_instance.start = AsyncMock() + + agent = RICommunicationAgent("test@server", "password", address="tcp://localhost:5555", bind=False) + + # --- Act & Assert --- + with caplog.at_level("ERROR"): + await agent.setup(max_retries=1) + + # Ensure the unpacking exception was logged + assert "Error unpacking negotiation data" in caplog.text + + # Ensure no command agent was started + fake_agent_instance.start.assert_not_awaited() + + # Ensure no behaviour was attached + assert not any(isinstance(b, agent.ListenBehaviour) for b in agent.behaviours) \ No newline at end of file diff --git a/uv.lock b/uv.lock index 07bdb8f..6b5375b 100644 --- a/uv.lock +++ b/uv.lock @@ -292,6 +292,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, ] +[[package]] +name = "coverage" +version = "7.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/38/ee22495420457259d2f3390309505ea98f98a5eed40901cf62196abad006/coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050", size = 811905, upload-time = "2025-10-15T15:15:08.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/7f/85e4dfe65e400645464b25c036a26ac226cf3a69d4a50c3934c532491cdd/coverage-7.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1", size = 216129, upload-time = "2025-10-15T15:13:25.371Z" }, + { url = "https://files.pythonhosted.org/packages/96/5d/dc5fa98fea3c175caf9d360649cb1aa3715e391ab00dc78c4c66fabd7356/coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be", size = 216380, upload-time = "2025-10-15T15:13:26.976Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f5/3da9cc9596708273385189289c0e4d8197d37a386bdf17619013554b3447/coverage-7.11.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d", size = 247375, upload-time = "2025-10-15T15:13:28.923Z" }, + { url = "https://files.pythonhosted.org/packages/65/6c/f7f59c342359a235559d2bc76b0c73cfc4bac7d61bb0df210965cb1ecffd/coverage-7.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82", size = 249978, upload-time = "2025-10-15T15:13:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8c/042dede2e23525e863bf1ccd2b92689692a148d8b5fd37c37899ba882645/coverage-7.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52", size = 251253, upload-time = "2025-10-15T15:13:32.174Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/3c58df67bfa809a7bddd786356d9c5283e45d693edb5f3f55d0986dd905a/coverage-7.11.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b", size = 247591, upload-time = "2025-10-15T15:13:34.147Z" }, + { url = "https://files.pythonhosted.org/packages/26/5b/c7f32efd862ee0477a18c41e4761305de6ddd2d49cdeda0c1116227570fd/coverage-7.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4", size = 249411, upload-time = "2025-10-15T15:13:38.425Z" }, + { url = "https://files.pythonhosted.org/packages/76/b5/78cb4f1e86c1611431c990423ec0768122905b03837e1b4c6a6f388a858b/coverage-7.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd", size = 247303, upload-time = "2025-10-15T15:13:40.464Z" }, + { url = "https://files.pythonhosted.org/packages/87/c9/23c753a8641a330f45f221286e707c427e46d0ffd1719b080cedc984ec40/coverage-7.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc", size = 247157, upload-time = "2025-10-15T15:13:42.087Z" }, + { url = "https://files.pythonhosted.org/packages/c5/42/6e0cc71dc8a464486e944a4fa0d85bdec031cc2969e98ed41532a98336b9/coverage-7.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48", size = 248921, upload-time = "2025-10-15T15:13:43.715Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1c/743c2ef665e6858cccb0f84377dfe3a4c25add51e8c7ef19249be92465b6/coverage-7.11.0-cp313-cp313-win32.whl", hash = "sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040", size = 218526, upload-time = "2025-10-15T15:13:45.336Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d5/226daadfd1bf8ddbccefbd3aa3547d7b960fb48e1bdac124e2dd13a2b71a/coverage-7.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05", size = 219317, upload-time = "2025-10-15T15:13:47.401Z" }, + { url = "https://files.pythonhosted.org/packages/97/54/47db81dcbe571a48a298f206183ba8a7ba79200a37cd0d9f4788fcd2af4a/coverage-7.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a", size = 217948, upload-time = "2025-10-15T15:13:49.096Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8b/cb68425420154e7e2a82fd779a8cc01549b6fa83c2ad3679cd6c088ebd07/coverage-7.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b", size = 216837, upload-time = "2025-10-15T15:13:51.09Z" }, + { url = "https://files.pythonhosted.org/packages/33/55/9d61b5765a025685e14659c8d07037247de6383c0385757544ffe4606475/coverage-7.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37", size = 217061, upload-time = "2025-10-15T15:13:52.747Z" }, + { url = "https://files.pythonhosted.org/packages/52/85/292459c9186d70dcec6538f06ea251bc968046922497377bf4a1dc9a71de/coverage-7.11.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de", size = 258398, upload-time = "2025-10-15T15:13:54.45Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e2/46edd73fb8bf51446c41148d81944c54ed224854812b6ca549be25113ee0/coverage-7.11.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f", size = 260574, upload-time = "2025-10-15T15:13:56.145Z" }, + { url = "https://files.pythonhosted.org/packages/07/5e/1df469a19007ff82e2ca8fe509822820a31e251f80ee7344c34f6cd2ec43/coverage-7.11.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c", size = 262797, upload-time = "2025-10-15T15:13:58.635Z" }, + { url = "https://files.pythonhosted.org/packages/f9/50/de216b31a1434b94d9b34a964c09943c6be45069ec704bfc379d8d89a649/coverage-7.11.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa", size = 257361, upload-time = "2025-10-15T15:14:00.409Z" }, + { url = "https://files.pythonhosted.org/packages/82/1e/3f9f8344a48111e152e0fd495b6fff13cc743e771a6050abf1627a7ba918/coverage-7.11.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740", size = 260349, upload-time = "2025-10-15T15:14:02.188Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/3f52741f9e7d82124272f3070bbe316006a7de1bad1093f88d59bfc6c548/coverage-7.11.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef", size = 258114, upload-time = "2025-10-15T15:14:03.907Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8b/918f0e15f0365d50d3986bbd3338ca01178717ac5678301f3f547b6619e6/coverage-7.11.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0", size = 256723, upload-time = "2025-10-15T15:14:06.324Z" }, + { url = "https://files.pythonhosted.org/packages/44/9e/7776829f82d3cf630878a7965a7d70cc6ca94f22c7d20ec4944f7148cb46/coverage-7.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca", size = 259238, upload-time = "2025-10-15T15:14:08.002Z" }, + { url = "https://files.pythonhosted.org/packages/9a/b8/49cf253e1e7a3bedb85199b201862dd7ca4859f75b6cf25ffa7298aa0760/coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2", size = 219180, upload-time = "2025-10-15T15:14:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e1/1a541703826be7ae2125a0fb7f821af5729d56bb71e946e7b933cc7a89a4/coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268", size = 220241, upload-time = "2025-10-15T15:14:11.471Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/5ee0e0a08621140fd418ec4020f595b4d52d7eb429ae6a0c6542b4ba6f14/coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836", size = 218510, upload-time = "2025-10-15T15:14:13.46Z" }, + { url = "https://files.pythonhosted.org/packages/f4/06/e923830c1985ce808e40a3fa3eb46c13350b3224b7da59757d37b6ce12b8/coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497", size = 216110, upload-time = "2025-10-15T15:14:15.157Z" }, + { url = "https://files.pythonhosted.org/packages/42/82/cdeed03bfead45203fb651ed756dfb5266028f5f939e7f06efac4041dad5/coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e", size = 216395, upload-time = "2025-10-15T15:14:16.863Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ba/e1c80caffc3199aa699813f73ff097bc2df7b31642bdbc7493600a8f1de5/coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1", size = 247433, upload-time = "2025-10-15T15:14:18.589Z" }, + { url = "https://files.pythonhosted.org/packages/80/c0/5b259b029694ce0a5bbc1548834c7ba3db41d3efd3474489d7efce4ceb18/coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca", size = 249970, upload-time = "2025-10-15T15:14:20.307Z" }, + { url = "https://files.pythonhosted.org/packages/8c/86/171b2b5e1aac7e2fd9b43f7158b987dbeb95f06d1fbecad54ad8163ae3e8/coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd", size = 251324, upload-time = "2025-10-15T15:14:22.419Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/7e10414d343385b92024af3932a27a1caf75c6e27ee88ba211221ff1a145/coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43", size = 247445, upload-time = "2025-10-15T15:14:24.205Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/e4f966b21f5be8c4bf86ad75ae94efa0de4c99c7bbb8114476323102e345/coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777", size = 249324, upload-time = "2025-10-15T15:14:26.234Z" }, + { url = "https://files.pythonhosted.org/packages/00/a2/8479325576dfcd909244d0df215f077f47437ab852ab778cfa2f8bf4d954/coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2", size = 247261, upload-time = "2025-10-15T15:14:28.42Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d8/3a9e2db19d94d65771d0f2e21a9ea587d11b831332a73622f901157cc24b/coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d", size = 247092, upload-time = "2025-10-15T15:14:30.784Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b1/bbca3c472544f9e2ad2d5116b2379732957048be4b93a9c543fcd0207e5f/coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4", size = 248755, upload-time = "2025-10-15T15:14:32.585Z" }, + { url = "https://files.pythonhosted.org/packages/89/49/638d5a45a6a0f00af53d6b637c87007eb2297042186334e9923a61aa8854/coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721", size = 218793, upload-time = "2025-10-15T15:14:34.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/cc/b675a51f2d068adb3cdf3799212c662239b0ca27f4691d1fff81b92ea850/coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad", size = 219587, upload-time = "2025-10-15T15:14:37.047Z" }, + { url = "https://files.pythonhosted.org/packages/93/98/5ac886876026de04f00820e5094fe22166b98dcb8b426bf6827aaf67048c/coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479", size = 218168, upload-time = "2025-10-15T15:14:38.861Z" }, + { url = "https://files.pythonhosted.org/packages/14/d1/b4145d35b3e3ecf4d917e97fc8895bcf027d854879ba401d9ff0f533f997/coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f", size = 216850, upload-time = "2025-10-15T15:14:40.651Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d1/7f645fc2eccd318369a8a9948acc447bb7c1ade2911e31d3c5620544c22b/coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e", size = 217071, upload-time = "2025-10-15T15:14:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/64d124649db2737ceced1dfcbdcb79898d5868d311730f622f8ecae84250/coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44", size = 258570, upload-time = "2025-10-15T15:14:44.542Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3f/6f5922f80dc6f2d8b2c6f974835c43f53eb4257a7797727e6ca5b7b2ec1f/coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3", size = 260738, upload-time = "2025-10-15T15:14:46.436Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5f/9e883523c4647c860b3812b417a2017e361eca5b635ee658387dc11b13c1/coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b", size = 262994, upload-time = "2025-10-15T15:14:48.3Z" }, + { url = "https://files.pythonhosted.org/packages/07/bb/43b5a8e94c09c8bf51743ffc65c4c841a4ca5d3ed191d0a6919c379a1b83/coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d", size = 257282, upload-time = "2025-10-15T15:14:50.236Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e5/0ead8af411411330b928733e1d201384b39251a5f043c1612970310e8283/coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2", size = 260430, upload-time = "2025-10-15T15:14:52.413Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/03dd8bb0ba5b971620dcaac145461950f6d8204953e535d2b20c6b65d729/coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e", size = 258190, upload-time = "2025-10-15T15:14:54.268Z" }, + { url = "https://files.pythonhosted.org/packages/45/ae/28a9cce40bf3174426cb2f7e71ee172d98e7f6446dff936a7ccecee34b14/coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996", size = 256658, upload-time = "2025-10-15T15:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7c/3a44234a8599513684bfc8684878fd7b126c2760f79712bb78c56f19efc4/coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11", size = 259342, upload-time = "2025-10-15T15:14:58.538Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e6/0108519cba871af0351725ebdb8660fd7a0fe2ba3850d56d32490c7d9b4b/coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73", size = 219568, upload-time = "2025-10-15T15:15:00.382Z" }, + { url = "https://files.pythonhosted.org/packages/c9/76/44ba876e0942b4e62fdde23ccb029ddb16d19ba1bef081edd00857ba0b16/coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547", size = 220687, upload-time = "2025-10-15T15:15:02.322Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0c/0df55ecb20d0d0ed5c322e10a441775e1a3a5d78c60f0c4e1abfe6fcf949/coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3", size = 218711, upload-time = "2025-10-15T15:15:04.575Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761, upload-time = "2025-10-15T15:15:06.439Z" }, +] + [[package]] name = "cryptography" version = "43.0.1" @@ -637,6 +698,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -1218,6 +1288,10 @@ dependencies = [ { name = "pyaudio" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, { name = "pyzmq" }, { name = "silero-vad" }, { name = "spade" }, @@ -1233,6 +1307,10 @@ requires-dist = [ { name = "pyaudio", specifier = ">=0.2.14" }, { name = "pydantic", specifier = ">=2.12.0" }, { name = "pydantic-settings", specifier = ">=2.11.0" }, + { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-asyncio", specifier = ">=1.2.0" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "pytest-mock", specifier = ">=3.15.1" }, { name = "pyzmq", specifier = ">=27.1.0" }, { name = "silero-vad", specifier = ">=6.0.0" }, { name = "spade", specifier = ">=4.1.0" }, @@ -1240,6 +1318,15 @@ requires-dist = [ { name = "uvicorn", specifier = ">=0.37.0" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "propcache" version = "0.4.0" @@ -1544,6 +1631,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, ] +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"