feat: agent structure and implementation new
architecture with unit tests ref: N25B-205
This commit is contained in:
7
.vscode/settings.json
vendored
Normal file
7
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"python.testing.pytestArgs": [
|
||||||
|
"test"
|
||||||
|
],
|
||||||
|
"python.testing.unittestEnabled": false,
|
||||||
|
"python.testing.pytestEnabled": true
|
||||||
|
}
|
||||||
219
main.py
219
main.py
@@ -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
|
|
||||||
@@ -11,6 +11,10 @@ dependencies = [
|
|||||||
"pyaudio>=0.2.14",
|
"pyaudio>=0.2.14",
|
||||||
"pydantic>=2.12.0",
|
"pydantic>=2.12.0",
|
||||||
"pydantic-settings>=2.11.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",
|
"pyzmq>=27.1.0",
|
||||||
"silero-vad>=6.0.0",
|
"silero-vad>=6.0.0",
|
||||||
"spade>=4.1.0",
|
"spade>=4.1.0",
|
||||||
|
|||||||
60
src/control_backend/agents/ri_command_agent.py
Normal file
60
src/control_backend/agents/ri_command_agent.py
Normal file
@@ -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)
|
||||||
138
src/control_backend/agents/ri_communication_agent.py
Normal file
138
src/control_backend/agents/ri_communication_agent.py
Normal file
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
@@ -1,15 +1,27 @@
|
|||||||
|
from re import L
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
class ZMQSettings(BaseModel):
|
class ZMQSettings(BaseModel):
|
||||||
internal_comm_address: str = "tcp://localhost:5560"
|
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):
|
class Settings(BaseSettings):
|
||||||
app_title: str = "PepperPlus"
|
app_title: str = "PepperPlus"
|
||||||
|
|
||||||
ui_url: str = "http://localhost:5173"
|
ui_url: str = "http://localhost:5173"
|
||||||
|
|
||||||
zmq_settings: ZMQSettings = ZMQSettings()
|
zmq_settings: ZMQSettings = ZMQSettings()
|
||||||
|
|
||||||
|
agent_settings: AgentSettings = AgentSettings()
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_file=".env")
|
model_config = SettingsConfigDict(env_file=".env")
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import zmq
|
|||||||
|
|
||||||
# Internal imports
|
# Internal imports
|
||||||
from control_backend.agents.test_agent import TestAgent
|
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.api.v1.router import api_router
|
||||||
from control_backend.core.config import settings
|
from control_backend.core.config import settings
|
||||||
from control_backend.core.zmq_context import context
|
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)
|
logger.info("Internal publishing socket bound to %s", internal_comm_socket)
|
||||||
|
|
||||||
# Initiate agents
|
# Initiate agents
|
||||||
test_agent = TestAgent("test_agent@localhost", "test_agent")
|
logger.info(settings.agent_settings.ri_communication_agent_name + '@' + settings.agent_settings.host)
|
||||||
await test_agent.start()
|
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
|
yield
|
||||||
|
|
||||||
logger.info("%s shutting down.", app.title)
|
logger.info("%s shutting down.", app.title)
|
||||||
|
|||||||
84
test/unit/test_ri_commands_agent.py
Normal file
84
test/unit/test_ri_commands_agent.py
Normal file
@@ -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
|
||||||
498
test/unit/test_ri_communication_agent.py
Normal file
498
test/unit/test_ri_communication_agent.py
Normal file
@@ -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)
|
||||||
141
uv.lock
generated
141
uv.lock
generated
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "cryptography"
|
name = "cryptography"
|
||||||
version = "43.0.1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "itsdangerous"
|
name = "itsdangerous"
|
||||||
version = "2.2.0"
|
version = "2.2.0"
|
||||||
@@ -1218,6 +1288,10 @@ dependencies = [
|
|||||||
{ name = "pyaudio" },
|
{ name = "pyaudio" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-asyncio" },
|
||||||
|
{ name = "pytest-cov" },
|
||||||
|
{ name = "pytest-mock" },
|
||||||
{ name = "pyzmq" },
|
{ name = "pyzmq" },
|
||||||
{ name = "silero-vad" },
|
{ name = "silero-vad" },
|
||||||
{ name = "spade" },
|
{ name = "spade" },
|
||||||
@@ -1233,6 +1307,10 @@ requires-dist = [
|
|||||||
{ name = "pyaudio", specifier = ">=0.2.14" },
|
{ name = "pyaudio", specifier = ">=0.2.14" },
|
||||||
{ name = "pydantic", specifier = ">=2.12.0" },
|
{ name = "pydantic", specifier = ">=2.12.0" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.11.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 = "pyzmq", specifier = ">=27.1.0" },
|
||||||
{ name = "silero-vad", specifier = ">=6.0.0" },
|
{ name = "silero-vad", specifier = ">=6.0.0" },
|
||||||
{ name = "spade", specifier = ">=4.1.0" },
|
{ name = "spade", specifier = ">=4.1.0" },
|
||||||
@@ -1240,6 +1318,15 @@ requires-dist = [
|
|||||||
{ name = "uvicorn", specifier = ">=0.37.0" },
|
{ 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]]
|
[[package]]
|
||||||
name = "propcache"
|
name = "propcache"
|
||||||
version = "0.4.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "python-dateutil"
|
name = "python-dateutil"
|
||||||
version = "2.9.0.post0"
|
version = "2.9.0.post0"
|
||||||
|
|||||||
Reference in New Issue
Block a user