From 8812c5f5f9db85ab28ee8769b188de534284383e Mon Sep 17 00:00:00 2001 From: Kasper Date: Sat, 18 Oct 2025 13:48:15 +0200 Subject: [PATCH 1/8] chore: update .gitignore A MacOS specific ignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4d2fe1b..f6ad342 100644 --- a/.gitignore +++ b/.gitignore @@ -215,7 +215,8 @@ __marimo__/ # Streamlit .streamlit/secrets.toml - +# MacOS +.DS_Store From 31882f8d63140515f011a427ad8cc178138abab8 Mon Sep 17 00:00:00 2001 From: Kasper Date: Sat, 18 Oct 2025 17:50:17 +0200 Subject: [PATCH 2/8] feat: add BDI core agent Main BDI brain structure implemented. Still some TODOs left, and very basic implementation (only one belief "user_said(Message)" and every message is sent straight to a function which is responsible for getting an LLM response. ref: N25B-197 --- pyproject.toml | 1 + src/control_backend/agents/bdi/__init__.py | 0 src/control_backend/agents/bdi/bdi_core.py | 32 +++++++++++ .../agents/bdi/behaviours/__init__.py | 0 .../agents/bdi/behaviours/belief_setter.py | 57 +++++++++++++++++++ src/control_backend/agents/bdi/rules.asl | 3 + src/control_backend/core/config.py | 11 +++- src/control_backend/main.py | 28 +++++++-- uv.lock | 28 +++++++++ 9 files changed, 153 insertions(+), 7 deletions(-) create mode 100644 src/control_backend/agents/bdi/__init__.py create mode 100644 src/control_backend/agents/bdi/bdi_core.py create mode 100644 src/control_backend/agents/bdi/behaviours/__init__.py create mode 100644 src/control_backend/agents/bdi/behaviours/belief_setter.py create mode 100644 src/control_backend/agents/bdi/rules.asl diff --git a/pyproject.toml b/pyproject.toml index d0a617f..e2f3b8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "pyzmq>=27.1.0", "silero-vad>=6.0.0", "spade>=4.1.0", + "spade-bdi>=0.3.2", "torch>=2.8.0", "uvicorn>=0.37.0", ] diff --git a/src/control_backend/agents/bdi/__init__.py b/src/control_backend/agents/bdi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/control_backend/agents/bdi/bdi_core.py b/src/control_backend/agents/bdi/bdi_core.py new file mode 100644 index 0000000..d0c8b6c --- /dev/null +++ b/src/control_backend/agents/bdi/bdi_core.py @@ -0,0 +1,32 @@ +import logging + +import agentspeak +from spade_bdi.bdi import BDIAgent + +from control_backend.agents.bdi.behaviours.belief_setter import BeliefSetter + +class BDICore(BDIAgent): + """ + TODO: docs + """ + logger = logging.getLogger("BDI Core") + + async def setup(self): + belief_setter = BeliefSetter() + self.add_behaviour(belief_setter) + + def add_custom_actions(self, actions): + @actions.add(".reply", 1) + def _reply(agent, term, intention): + message = agentspeak.grounded(term.args[0], intention.scope) + self.logger.info(f"Replying to message: {message}") + reply = self._send_to_llm(message) + self.logger.info(f"Received reply: {reply}") + + yield + + def _send_to_llm(self, message) -> str: + """TODO: implement""" + return f"This is a reply to {message}" + + diff --git a/src/control_backend/agents/bdi/behaviours/__init__.py b/src/control_backend/agents/bdi/behaviours/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/control_backend/agents/bdi/behaviours/belief_setter.py b/src/control_backend/agents/bdi/behaviours/belief_setter.py new file mode 100644 index 0000000..c8d4c2e --- /dev/null +++ b/src/control_backend/agents/bdi/behaviours/belief_setter.py @@ -0,0 +1,57 @@ +import asyncio +import json +import logging + +from spade.agent import Message +from spade.behaviour import CyclicBehaviour +from spade_bdi.bdi import BDIAgent + +from control_backend.core.config import settings + +class BeliefSetter(CyclicBehaviour): + """ + TODO: docs + """ + agent: BDIAgent + logger = logging.getLogger("BDI/Belief Setter") + async def run(self): + msg = await self.receive(timeout=0.1) + if msg: + self.logger.info(f"Received message {msg.body}") + self._process_message(msg) + await asyncio.sleep(1) + + def _process_message(self, message: Message): + sender = message.sender.node # removes host from jid and converts to str + self.logger.debug("Sender: %s", sender) + + match sender: + case settings.agent_settings.belief_collector_agent_name: + self.logger.debug("Processing message from belief collector.") + self._process_belief_message(message) + case _: + pass + + def _process_belief_message(self, message: Message): + if not message.body: return + + match message.thread: + case "beliefs": + try: + beliefs: dict[str, list[list[str]]] = json.loads(message.body) + self._set_beliefs(beliefs) + except json.JSONDecodeError as e: + self.logger.error("Could not decode beliefs into JSON format: %s", e) + case _: + pass + + + def _set_beliefs(self, beliefs: dict[str, list[list[str]]]): + if self.agent.bdi is None: + self.logger.warning("Cannot set beliefs, since agent's BDI is not yet initialized.") + return + + for belief, arguments_list in beliefs.items(): + for arguments in arguments_list: + self.agent.bdi.set_belief(belief, *arguments) + self.logger.info("Set belief %s with arguments %s", belief, arguments) diff --git a/src/control_backend/agents/bdi/rules.asl b/src/control_backend/agents/bdi/rules.asl new file mode 100644 index 0000000..41660a4 --- /dev/null +++ b/src/control_backend/agents/bdi/rules.asl @@ -0,0 +1,3 @@ ++user_said(Message) : not responded <- + +responded; + .reply(Message). diff --git a/src/control_backend/core/config.py b/src/control_backend/core/config.py index fca21b3..07a828d 100644 --- a/src/control_backend/core/config.py +++ b/src/control_backend/core/config.py @@ -1,15 +1,24 @@ +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" + 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..5ec0276 100644 --- a/src/control_backend/main.py +++ b/src/control_backend/main.py @@ -1,18 +1,24 @@ +# Standard library imports +import asyncio +import json + # External imports import contextlib from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware import logging +from spade.agent import Agent, Message +from spade.behaviour import OneShotBehaviour import zmq # Internal imports -from control_backend.agents.test_agent import TestAgent +from control_backend.agents.bdi.bdi_core import BDICore from control_backend.api.v1.router import api_router -from control_backend.core.config import settings +from control_backend.core.config import AgentSettings, settings from control_backend.core.zmq_context import context logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) +logging.basicConfig(level=logging.DEBUG) @contextlib.asynccontextmanager async def lifespan(app: FastAPI): @@ -26,13 +32,23 @@ 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() + bdi_core = BDICore(settings.agent_settings.bdi_core_agent_name + '@' + settings.agent_settings.host, settings.agent_settings.bdi_core_agent_name, "src/control_backend/agents/bdi/rules.asl") + await bdi_core.start() + # -----------TEMORARY SECTION------------- + belief_collector = Agent(settings.agent_settings.belief_collector_agent_name + '@' + settings.agent_settings.host, settings.agent_settings.belief_collector_agent_name) + await belief_collector.start() + + class SendMessageBehaviour(OneShotBehaviour): + async def run(self): + await self.send(Message(bdi_core.jid, belief_collector.jid, json.dumps({"user_said": [["Hello World!"]]}), "beliefs")) + + belief_collector.add_behaviour(SendMessageBehaviour()) + # -----------TEMORARY SECTION------------- + yield logger.info("%s shutting down.", app.title) - # if __name__ == "__main__": app = FastAPI(title=settings.app_title, lifespan=lifespan) diff --git a/uv.lock b/uv.lock index 07bdb8f..bddde4d 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,18 @@ resolution-markers = [ "python_full_version < '3.14'", ] +[[package]] +name = "agentspeak" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/a3/f8e9292cfd47aa5558f4578c498ca12c068a3a1d60ddfd0af13a87c1e47a/agentspeak-0.2.2.tar.gz", hash = "sha256:7c7fcf689fd54460597be1798ce11535f42a60c3d79af59381af3e13ef7a41bb", size = 59628, upload-time = "2024-03-21T11:55:39.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/b5/e95cbd9d9e999ac8dc4e0bb7a940112a2751cf98880b4ff0626e53d14249/agentspeak-0.2.2-py3-none-any.whl", hash = "sha256:9b454bc0adf63cb0d73fb4a3a9a489e7d892d5fbf17f750de532670736c0c4dd", size = 61628, upload-time = "2024-03-21T11:55:36.741Z" }, +] + [[package]] name = "aiodns" version = "3.5.0" @@ -1221,6 +1233,7 @@ dependencies = [ { name = "pyzmq" }, { name = "silero-vad" }, { name = "spade" }, + { name = "spade-bdi" }, { name = "torch" }, { name = "uvicorn" }, ] @@ -1236,6 +1249,7 @@ requires-dist = [ { name = "pyzmq", specifier = ">=27.1.0" }, { name = "silero-vad", specifier = ">=6.0.0" }, { name = "spade", specifier = ">=4.1.0" }, + { name = "spade-bdi", specifier = ">=0.3.2" }, { name = "torch", specifier = ">=2.8.0" }, { name = "uvicorn", specifier = ">=0.37.0" }, ] @@ -1941,6 +1955,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/06/21d0e937f4daa905a9a007700f59b06de644a44e5f594c3428c3ff93ca39/spade-4.1.0-py2.py3-none-any.whl", hash = "sha256:8b20e7fcb12f836cb0504e9da31f7bd867c7276440e19ebca864aecabc71b114", size = 37033, upload-time = "2025-05-22T17:19:06.524Z" }, ] +[[package]] +name = "spade-bdi" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "agentspeak" }, + { name = "loguru" }, + { name = "spade" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/b4/d52d9d06ad17d4b3a90ca11b64a14194f3f944f561f4da1395ce3fe3994d/spade_bdi-0.3.2.tar.gz", hash = "sha256:5d03661425f78771e39f3592f8a602ff8240465682b79d333926d3e562657d81", size = 21208, upload-time = "2025-01-03T14:16:43.755Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/c2/986de9abaad805d92a33912ab06b08bb81bd404bcef9ad0f2fd7a09f274b/spade_bdi-0.3.2-py2.py3-none-any.whl", hash = "sha256:2039271f586b108660a0a6a951d9ec815197caf14915317c6eec19ff496c2cff", size = 7416, upload-time = "2025-01-03T14:16:42.226Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.40" From 4cf1e5aaf73c51bf9605ca872e2b319d388214aa Mon Sep 17 00:00:00 2001 From: Storm Date: Tue, 21 Oct 2025 12:33:47 +0200 Subject: [PATCH 3/8] docs: added docstrings to bdi_core and BeliefSetter behaviour ref: N25B-197 --- src/control_backend/agents/bdi/bdi_core.py | 5 ++++- src/control_backend/agents/bdi/behaviours/belief_setter.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/control_backend/agents/bdi/bdi_core.py b/src/control_backend/agents/bdi/bdi_core.py index d0c8b6c..69fb5e5 100644 --- a/src/control_backend/agents/bdi/bdi_core.py +++ b/src/control_backend/agents/bdi/bdi_core.py @@ -7,7 +7,10 @@ from control_backend.agents.bdi.behaviours.belief_setter import BeliefSetter class BDICore(BDIAgent): """ - TODO: docs + This is the Brain agent that does the belief inference with AgentSpeak. + This is a continous process that happens automatically in the background. + This class contains all the actions that can be called from AgentSpeak plans. + It has the BeliefSetter behaviour. """ logger = logging.getLogger("BDI Core") diff --git a/src/control_backend/agents/bdi/behaviours/belief_setter.py b/src/control_backend/agents/bdi/behaviours/belief_setter.py index c8d4c2e..777dda3 100644 --- a/src/control_backend/agents/bdi/behaviours/belief_setter.py +++ b/src/control_backend/agents/bdi/behaviours/belief_setter.py @@ -10,10 +10,13 @@ from control_backend.core.config import settings class BeliefSetter(CyclicBehaviour): """ - TODO: docs + This is the behaviour that the BDI agent runs. + This behaviour waits for incoming message and processes it based on sender. + Currently, t only waits for messages containing beliefs from Belief Collector and adds these to its KB. """ agent: BDIAgent logger = logging.getLogger("BDI/Belief Setter") + async def run(self): msg = await self.receive(timeout=0.1) if msg: From 2069ac1a9332ee3916f4b64a1a28d19e13f2513e Mon Sep 17 00:00:00 2001 From: Kasper Date: Wed, 22 Oct 2025 14:05:45 +0200 Subject: [PATCH 4/8] feat: automatic testing This commit adds a .gitlab-ci.yml file, which is responsible for defining jobs to be run (in this case only running the test suite) ref: N25B-65 --- .gitlab-ci.yml | 26 ++++ README.md | 9 +- pyproject.toml | 8 ++ src/control_backend/agents/test_agent.py | 4 - test/unit/test_temp.py | 6 + uv.lock | 149 +++++++++++++++++++++++ 6 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 .gitlab-ci.yml delete mode 100644 src/control_backend/agents/test_agent.py create mode 100644 test/unit/test_temp.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..f4e1883 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,26 @@ +# ---------- GLOBAL SETUP ---------- # +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + +stages: + - install + - lint + - test + +variables: + UV_VERSION: "0.9.4" + PYTHON_VERSION: "3.13" + BASE_LAYER: trixie-slim + +default: + image: ghcr.io/astral-sh/uv:$UV_VERSION-python$PYTHON_VERSION-$BASE_LAYER + +# ---------- TESTING ---------- # +test: + stage: test + tags: + - test + script: + - uv run --only-group test pytest + diff --git a/README.md b/README.md index 2f35dc0..c2a8702 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ We begin by installing UV (very nice utility for managing packages and Python version): ```bash -# On macOS and Linux. +# On MacOS and Linux. curl -LsSf https://astral.sh/uv/install.sh | sh ``` ```bash @@ -23,6 +23,13 @@ To run the project (development server), execute the following command (while in uv run fastapi dev src/control_backend/main.py ``` +## Testing +Testing happens automatically when opening a merge request to any branch. If you want to manually run the test suite, you can do so by running the following: + +```bash +uv run --only-group test pytest +``` + ## GitHooks To activate automatic commits/branch name checks run: diff --git a/pyproject.toml b/pyproject.toml index d0a617f..5bb5563 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,3 +17,11 @@ dependencies = [ "torch>=2.8.0", "uvicorn>=0.37.0", ] + +[dependency-groups] +test = [ + "pytest>=8.4.2", + "pytest-asyncio>=1.2.0", + "pytest-cov>=7.0.0", + "pytest-mock>=3.15.1", +] diff --git a/src/control_backend/agents/test_agent.py b/src/control_backend/agents/test_agent.py deleted file mode 100644 index 7a9707b..0000000 --- a/src/control_backend/agents/test_agent.py +++ /dev/null @@ -1,4 +0,0 @@ -from spade.agent import Agent - -class TestAgent(Agent): - pass \ No newline at end of file diff --git a/test/unit/test_temp.py b/test/unit/test_temp.py new file mode 100644 index 0000000..ac449ca --- /dev/null +++ b/test/unit/test_temp.py @@ -0,0 +1,6 @@ +""" +Temporary file to demonstrate unit testing. +""" + +def test_temp(): + assert True diff --git a/uv.lock b/uv.lock index 07bdb8f..4b39c29 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.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -1225,6 +1295,14 @@ dependencies = [ { name = "uvicorn" }, ] +[package.dev-dependencies] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, +] + [package.metadata] requires-dist = [ { name = "fastapi", extras = ["all"], specifier = ">=0.115.6" }, @@ -1240,6 +1318,23 @@ requires-dist = [ { name = "uvicorn", specifier = ">=0.37.0" }, ] +[package.metadata.requires-dev] +test = [ + { 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" }, +] + +[[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 +1639,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" From 7e7d98a2fcf54ade6d0bee18b0c7d22ce189313d Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Wed, 22 Oct 2025 12:46:32 +0000 Subject: [PATCH 5/8] fix: set PYTHONPATH variable for pytest --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 5bb5563..00652a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,3 +25,6 @@ test = [ "pytest-cov>=7.0.0", "pytest-mock>=3.15.1", ] + +[tool.pytest.ini_options] +pythonpath = ["src"] From e057cf30032b5d2f70da94eca60b2385e0cce966 Mon Sep 17 00:00:00 2001 From: Kasper Date: Wed, 22 Oct 2025 14:51:20 +0200 Subject: [PATCH 6/8] test: add unit tests to BeliefCollector ref: N25B-197 --- pyproject.toml | 3 + src/control_backend/agents/bdi/bdi_core.py | 2 +- src/control_backend/main.py | 15 +- .../bdi/behaviours/test_belief_setter.py | 233 ++++++++++++++++++ 4 files changed, 239 insertions(+), 14 deletions(-) create mode 100644 test/unit/agents/bdi/behaviours/test_belief_setter.py diff --git a/pyproject.toml b/pyproject.toml index 7ef57da..6776668 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,3 +26,6 @@ test = [ "pytest-cov>=7.0.0", "pytest-mock>=3.15.1", ] + +[tool.pytest.ini_options] +pythonpath = ["src"] diff --git a/src/control_backend/agents/bdi/bdi_core.py b/src/control_backend/agents/bdi/bdi_core.py index 69fb5e5..7311061 100644 --- a/src/control_backend/agents/bdi/bdi_core.py +++ b/src/control_backend/agents/bdi/bdi_core.py @@ -5,7 +5,7 @@ from spade_bdi.bdi import BDIAgent from control_backend.agents.bdi.behaviours.belief_setter import BeliefSetter -class BDICore(BDIAgent): +class BDICoreAgent(BDIAgent): """ This is the Brain agent that does the belief inference with AgentSpeak. This is a continous process that happens automatically in the background. diff --git a/src/control_backend/main.py b/src/control_backend/main.py index 5ec0276..1f377c4 100644 --- a/src/control_backend/main.py +++ b/src/control_backend/main.py @@ -12,7 +12,7 @@ from spade.behaviour import OneShotBehaviour import zmq # Internal imports -from control_backend.agents.bdi.bdi_core import BDICore +from control_backend.agents.bdi.bdi_core import BDICoreAgent from control_backend.api.v1.router import api_router from control_backend.core.config import AgentSettings, settings from control_backend.core.zmq_context import context @@ -32,20 +32,9 @@ async def lifespan(app: FastAPI): logger.info("Internal publishing socket bound to %s", internal_comm_socket) # Initiate agents - bdi_core = BDICore(settings.agent_settings.bdi_core_agent_name + '@' + settings.agent_settings.host, settings.agent_settings.bdi_core_agent_name, "src/control_backend/agents/bdi/rules.asl") + bdi_core = BDICoreAgent(settings.agent_settings.bdi_core_agent_name + '@' + settings.agent_settings.host, settings.agent_settings.bdi_core_agent_name, "src/control_backend/agents/bdi/rules.asl") await bdi_core.start() - # -----------TEMORARY SECTION------------- - belief_collector = Agent(settings.agent_settings.belief_collector_agent_name + '@' + settings.agent_settings.host, settings.agent_settings.belief_collector_agent_name) - await belief_collector.start() - - class SendMessageBehaviour(OneShotBehaviour): - async def run(self): - await self.send(Message(bdi_core.jid, belief_collector.jid, json.dumps({"user_said": [["Hello World!"]]}), "beliefs")) - - belief_collector.add_behaviour(SendMessageBehaviour()) - # -----------TEMORARY SECTION------------- - yield logger.info("%s shutting down.", app.title) diff --git a/test/unit/agents/bdi/behaviours/test_belief_setter.py b/test/unit/agents/bdi/behaviours/test_belief_setter.py new file mode 100644 index 0000000..471d310 --- /dev/null +++ b/test/unit/agents/bdi/behaviours/test_belief_setter.py @@ -0,0 +1,233 @@ +import asyncio +import json +import logging +from unittest.mock import MagicMock, AsyncMock, call + +import pytest +from spade.agent import Message + +from control_backend.agents.bdi.behaviours.belief_setter import BeliefSetter + +# Define a constant for the collector agent name to use in tests +COLLECTOR_AGENT_NAME = "belief_collector" +COLLECTOR_AGENT_JID = f"{COLLECTOR_AGENT_NAME}@test" + + +@pytest.fixture +def mock_agent(mocker): + """Fixture to create a mock BDIAgent.""" + agent = MagicMock() + agent.bdi = MagicMock() + agent.jid = "bdi_agent@test" + return agent + + +@pytest.fixture +def belief_setter(mock_agent, mocker): + """Fixture to create an instance of BeliefSetter with a mocked agent.""" + # Patch the settings to use a predictable agent name + mocker.patch( + "control_backend.agents.bdi.behaviours.belief_setter.settings.agent_settings.belief_collector_agent_name", + COLLECTOR_AGENT_NAME + ) + # Patch asyncio.sleep to prevent tests from actually waiting + mocker.patch("asyncio.sleep", return_value=None) + + setter = BeliefSetter() + setter.agent = mock_agent + # Mock the receive method, we will control its return value in each test + setter.receive = AsyncMock() + return setter + + +@pytest.mark.asyncio +async def test_run_no_message_received(belief_setter, mocker): + """ + Test that when no message is received, _process_message is not called. + """ + # Arrange + belief_setter.receive.return_value = None + mocker.patch.object(belief_setter, "_process_message") + + # Act + await belief_setter.run() + + # Assert + belief_setter._process_message.assert_not_called() + + +@pytest.mark.asyncio +async def test_run_message_received(belief_setter, mocker): + """ + Test that when a message is received, _process_message is called. + """ + # Arrange + msg = Message(to="bdi_agent@test") + belief_setter.receive.return_value = msg + mocker.patch.object(belief_setter, "_process_message") + + # Act + await belief_setter.run() + + # Assert + belief_setter._process_message.assert_called_once_with(msg) + + +def test_process_message_from_belief_collector(belief_setter, mocker): + """ + Test processing a message from the correct belief collector agent. + """ + # Arrange + msg = Message(to="bdi_agent@test", sender=COLLECTOR_AGENT_JID) + mock_process_belief = mocker.patch.object(belief_setter, "_process_belief_message") + + # Act + belief_setter._process_message(msg) + + # Assert + mock_process_belief.assert_called_once_with(msg) + + +def test_process_message_from_other_agent(belief_setter, mocker): + """ + Test that messages from other agents are ignored. + """ + # Arrange + msg = Message(to="bdi_agent@test", sender="other_agent@test") + mock_process_belief = mocker.patch.object(belief_setter, "_process_belief_message") + + # Act + belief_setter._process_message(msg) + + # Assert + mock_process_belief.assert_not_called() + + +def test_process_belief_message_valid_json(belief_setter, mocker): + """ + Test processing a valid belief message with correct thread and JSON body. + """ + # Arrange + beliefs_payload = { + "is_hot": [["kitchen"]], + "is_clean": [["kitchen"], ["bathroom"]] + } + msg = Message( + to="bdi_agent@test", + sender=COLLECTOR_AGENT_JID, + body=json.dumps(beliefs_payload), + thread="beliefs" + ) + mock_set_beliefs = mocker.patch.object(belief_setter, "_set_beliefs") + + # Act + belief_setter._process_belief_message(msg) + + # Assert + mock_set_beliefs.assert_called_once_with(beliefs_payload) + + +def test_process_belief_message_invalid_json(belief_setter, mocker, caplog): + """ + Test that a message with invalid JSON is handled gracefully and an error is logged. + """ + # Arrange + msg = Message( + to="bdi_agent@test", + sender=COLLECTOR_AGENT_JID, + body="this is not a json string", + thread="beliefs" + ) + mock_set_beliefs = mocker.patch.object(belief_setter, "_set_beliefs") + + # Act + with caplog.at_level(logging.ERROR): + belief_setter._process_belief_message(msg) + + # Assert + mock_set_beliefs.assert_not_called() + assert "Could not decode beliefs into JSON format" in caplog.text + + +def test_process_belief_message_wrong_thread(belief_setter, mocker): + """ + Test that a message with an incorrect thread is ignored. + """ + # Arrange + msg = Message( + to="bdi_agent@test", + sender=COLLECTOR_AGENT_JID, + body='{"some": "data"}', + thread="not_beliefs" + ) + mock_set_beliefs = mocker.patch.object(belief_setter, "_set_beliefs") + + # Act + belief_setter._process_belief_message(msg) + + # Assert + mock_set_beliefs.assert_not_called() + +def test_process_belief_message_empty_body(belief_setter, mocker): + """ + Test that a message with an empty body is ignored. + """ + # Arrange + msg = Message( + to="bdi_agent@test", + sender=COLLECTOR_AGENT_JID, + body="", + thread="beliefs" + ) + mock_set_beliefs = mocker.patch.object(belief_setter, "_set_beliefs") + + # Act + belief_setter._process_belief_message(msg) + + # Assert + mock_set_beliefs.assert_not_called() + + +def test_set_beliefs_success(belief_setter, mock_agent, caplog): + """ + Test that beliefs are correctly set on the agent's BDI. + """ + # Arrange + beliefs_to_set = { + "is_hot": [["kitchen"], ["living_room"]], + "door_is": [["front_door", "closed"]] + } + + # Act + with caplog.at_level(logging.INFO): + belief_setter._set_beliefs(beliefs_to_set) + + # Assert + expected_calls = [ + call("is_hot", "kitchen"), + call("is_hot", "living_room"), + call("door_is", "front_door", "closed") + ] + mock_agent.bdi.set_belief.assert_has_calls(expected_calls, any_order=True) + assert mock_agent.bdi.set_belief.call_count == 3 + + # Check logs + assert "Set belief is_hot with arguments ['kitchen']" in caplog.text + assert "Set belief is_hot with arguments ['living_room']" in caplog.text + assert "Set belief door_is with arguments ['front_door', 'closed']" in caplog.text + + +def test_set_beliefs_bdi_not_initialized(belief_setter, mock_agent, caplog): + """ + Test that a warning is logged if the agent's BDI is not initialized. + """ + # Arrange + mock_agent.bdi = None # Simulate BDI not being ready + beliefs_to_set = {"is_hot": [["kitchen"]]} + + # Act + with caplog.at_level(logging.WARNING): + belief_setter._set_beliefs(beliefs_to_set) + + # Assert + assert "Cannot set beliefs, since agent's BDI is not yet initialized." in caplog.text From 675320a051ece46bb53e0d85d93aa50523480fd9 Mon Sep 17 00:00:00 2001 From: Kasper Date: Wed, 22 Oct 2025 14:54:01 +0200 Subject: [PATCH 7/8] chore: remove test_tempy.py --- test/unit/test_temp.py | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 test/unit/test_temp.py diff --git a/test/unit/test_temp.py b/test/unit/test_temp.py deleted file mode 100644 index ac449ca..0000000 --- a/test/unit/test_temp.py +++ /dev/null @@ -1,6 +0,0 @@ -""" -Temporary file to demonstrate unit testing. -""" - -def test_temp(): - assert True From a01b3c3b1472a5c0e8d20767675a2a6015642c69 Mon Sep 17 00:00:00 2001 From: Kasper Date: Wed, 22 Oct 2025 15:21:15 +0200 Subject: [PATCH 8/8] fix: mock correct libraries before tests --- test/conftest.py | 37 +++++++++++++++++++ .../bdi/behaviours/test_belief_setter.py | 37 ++++++++++--------- 2 files changed, 57 insertions(+), 17 deletions(-) create mode 100644 test/conftest.py diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..1e51aca --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,37 @@ +import sys +from unittest.mock import MagicMock + +import sys +from unittest.mock import MagicMock + +def pytest_configure(config): + """ + This hook runs at the start of the pytest session, before any tests are + collected. It mocks heavy or unavailable modules to prevent ImportErrors. + """ + # --- Mock spade and spade-bdi --- + mock_spade = MagicMock() + mock_spade.agent = MagicMock() + mock_spade.behaviour = MagicMock() + mock_spade_bdi = MagicMock() + mock_spade_bdi.bdi = MagicMock() + + mock_spade.agent.Message = MagicMock() + mock_spade.behaviour.CyclicBehaviour = type('CyclicBehaviour', (object,), {}) + mock_spade_bdi.bdi.BDIAgent = type('BDIAgent', (object,), {}) + + sys.modules['spade'] = mock_spade + sys.modules['spade.agent'] = mock_spade.agent + sys.modules['spade.behaviour'] = mock_spade.behaviour + sys.modules['spade_bdi'] = mock_spade_bdi + sys.modules['spade_bdi.bdi'] = mock_spade_bdi.bdi + + # --- Mock the config module to prevent Pydantic ImportError --- + mock_config_module = MagicMock() + + # The code under test does `from ... import settings`, so our mock module + # must have a `settings` attribute. We'll make it a MagicMock so we can + # configure it later in our tests using mocker.patch. + mock_config_module.settings = MagicMock() + + sys.modules['control_backend.core.config'] = mock_config_module diff --git a/test/unit/agents/bdi/behaviours/test_belief_setter.py b/test/unit/agents/bdi/behaviours/test_belief_setter.py index 471d310..8932834 100644 --- a/test/unit/agents/bdi/behaviours/test_belief_setter.py +++ b/test/unit/agents/bdi/behaviours/test_belief_setter.py @@ -1,10 +1,8 @@ -import asyncio import json import logging from unittest.mock import MagicMock, AsyncMock, call import pytest -from spade.agent import Message from control_backend.agents.bdi.behaviours.belief_setter import BeliefSetter @@ -40,6 +38,15 @@ def belief_setter(mock_agent, mocker): return setter +def create_mock_message(sender_node: str, body: str, thread: str) -> MagicMock: + """Helper function to create a configured mock message.""" + msg = MagicMock() + msg.sender.node = sender_node # MagicMock automatically creates nested mocks + msg.body = body + msg.thread = thread + return msg + + @pytest.mark.asyncio async def test_run_no_message_received(belief_setter, mocker): """ @@ -62,7 +69,7 @@ async def test_run_message_received(belief_setter, mocker): Test that when a message is received, _process_message is called. """ # Arrange - msg = Message(to="bdi_agent@test") + msg = MagicMock(); belief_setter.receive.return_value = msg mocker.patch.object(belief_setter, "_process_message") @@ -78,7 +85,7 @@ def test_process_message_from_belief_collector(belief_setter, mocker): Test processing a message from the correct belief collector agent. """ # Arrange - msg = Message(to="bdi_agent@test", sender=COLLECTOR_AGENT_JID) + msg = create_mock_message(sender_node=COLLECTOR_AGENT_NAME, body="", thread="") mock_process_belief = mocker.patch.object(belief_setter, "_process_belief_message") # Act @@ -93,7 +100,7 @@ def test_process_message_from_other_agent(belief_setter, mocker): Test that messages from other agents are ignored. """ # Arrange - msg = Message(to="bdi_agent@test", sender="other_agent@test") + msg = create_mock_message(sender_node="other_agent", body="", thread="") mock_process_belief = mocker.patch.object(belief_setter, "_process_belief_message") # Act @@ -112,9 +119,8 @@ def test_process_belief_message_valid_json(belief_setter, mocker): "is_hot": [["kitchen"]], "is_clean": [["kitchen"], ["bathroom"]] } - msg = Message( - to="bdi_agent@test", - sender=COLLECTOR_AGENT_JID, + msg = create_mock_message( + sender_node=COLLECTOR_AGENT_JID, body=json.dumps(beliefs_payload), thread="beliefs" ) @@ -132,9 +138,8 @@ def test_process_belief_message_invalid_json(belief_setter, mocker, caplog): Test that a message with invalid JSON is handled gracefully and an error is logged. """ # Arrange - msg = Message( - to="bdi_agent@test", - sender=COLLECTOR_AGENT_JID, + msg = create_mock_message( + sender_node=COLLECTOR_AGENT_JID, body="this is not a json string", thread="beliefs" ) @@ -154,9 +159,8 @@ def test_process_belief_message_wrong_thread(belief_setter, mocker): Test that a message with an incorrect thread is ignored. """ # Arrange - msg = Message( - to="bdi_agent@test", - sender=COLLECTOR_AGENT_JID, + msg = create_mock_message( + sender_node=COLLECTOR_AGENT_JID, body='{"some": "data"}', thread="not_beliefs" ) @@ -173,9 +177,8 @@ def test_process_belief_message_empty_body(belief_setter, mocker): Test that a message with an empty body is ignored. """ # Arrange - msg = Message( - to="bdi_agent@test", - sender=COLLECTOR_AGENT_JID, + msg = create_mock_message( + sender_node=COLLECTOR_AGENT_JID, body="", thread="beliefs" )