diff --git a/.github/workflows/publish-mango.yml b/.github/workflows/publish-mango.yml index c4b5b38a..ffc332a4 100644 --- a/.github/workflows/publish-mango.yml +++ b/.github/workflows/publish-mango.yml @@ -18,8 +18,7 @@ jobs: pip install virtualenv virtualenv venv source venv/bin/activate - pip3 install -r requirements.txt - pip3 install -e . + pip3 install -e .[test] sudo apt update sudo apt install --assume-yes mosquitto sudo service mosquitto start @@ -33,7 +32,8 @@ jobs: - name: Build package run: | source venv/bin/activate - python setup.py sdist bdist_wheel + python -m pip install build + python -m build - name: Publish package uses: pypa/gh-action-pypi-publish@release/v1 with: diff --git a/.github/workflows/test-mango.yml b/.github/workflows/test-mango.yml index 463c27c9..a7d1eea2 100644 --- a/.github/workflows/test-mango.yml +++ b/.github/workflows/test-mango.yml @@ -34,8 +34,7 @@ jobs: source venv/bin/activate pip3 install -U sphinx pip3 install -r docs/requirements.txt - pip3 install -r requirements.txt - pip3 install -e . + pip3 install -e .[test] brew install mosquitto brew services start mosquitto pip3 install pytest coverage ruff @@ -76,8 +75,7 @@ jobs: source venv/bin/activate pip3 install -U sphinx pip3 install -r docs/requirements.txt - pip3 install -r requirements.txt - pip3 install -e . + pip3 install -e .[test] sudo apt update sudo apt install --assume-yes mosquitto sudo service mosquitto start @@ -95,5 +93,8 @@ jobs: - name: Test+Coverage run: | source venv/bin/activate - coverage run -m pytest - coverage report + pytest --cov --cov-report=xml + - uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false diff --git a/docs/source/conf.py b/docs/source/conf.py index 1a044535..ac2440a6 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -11,7 +11,7 @@ author = "mango team" # The full version, including alpha/beta/rc tags -version = release = "2.0.0" +version = release = "2.1.0" # -- General configuration --------------------------------------------------- diff --git a/docs/source/index.rst b/docs/source/index.rst index 185b386b..d77c094f 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -30,6 +30,7 @@ Features message exchange role-api scheduling + topology codecs api_ref/index migration diff --git a/docs/source/topology.rst b/docs/source/topology.rst new file mode 100644 index 00000000..f7152c87 --- /dev/null +++ b/docs/source/topology.rst @@ -0,0 +1,81 @@ +================ +Topologies +================ + +The framework provides out of the box support for creating and distributing topologies to the agents. +This can be done using either :meth:`mango.create_topology` or :meth:`mango.per_node`. With the first method +a topology has to be created from scratch, and for every node you need to assign the agents. + +.. testcode:: + + import asyncio + from typing import Any + + from mango import Agent, run_with_tcp, create_topology + + class TopAgent(Agent): + counter: int = 0 + + def handle_message(self, content, meta: dict[str, Any]): + self.counter += 1 + + async def start_example(): + agents = [TopAgent(), TopAgent(), TopAgent()] + with create_topology() as topology: + id_1 = topology.add_node(agents[0]) + id_2 = topology.add_node(agents[1]) + id_3 = topology.add_node(agents[2]) + topology.add_edge(id_1, id_2) + topology.add_edge(id_1, id_3) + + async with run_with_tcp(1, *agents): + for neighbor in agents[0].neighbors(): + await agents[0].send_message("hello neighbors", neighbor) + await asyncio.sleep(0.1) + + print(agents[1].counter) + print(agents[2].counter) + + asyncio.run(start_example()) + +.. testoutput:: + + 1 + 1 + +The other method would be to use :meth:`mango.per_node`. Here you want to create a topology beforehand, this can be done using :meth:`mango.custom_topology` by providing +a networkx Graph. + +.. testcode:: + + import asyncio + from typing import Any + + from mango import Agent, run_with_tcp, per_node, complete_topology + + class TopAgent(Agent): + counter: int = 0 + + def handle_message(self, content, meta: dict[str, Any]): + self.counter += 1 + + async def start_example(): + topology = complete_topology(3) + for node in per_node(topology): + agent = TopAgent() + node.add(agent) + + async with run_with_tcp(1, *topology.agents): + for neighbor in topology.agents[0].neighbors(): + await topology.agents[0].send_message("hello neighbors", neighbor) + await asyncio.sleep(0.1) + + print(topology.agents[1].counter) + print(topology.agents[2].counter) + + asyncio.run(start_example()) + +.. testoutput:: + + 1 + 1 diff --git a/mango/__init__.py b/mango/__init__.py index 18808790..77d41a67 100644 --- a/mango/__init__.py +++ b/mango/__init__.py @@ -23,3 +23,10 @@ PROTOBUF, SerializationError, ) +from .express.topology import ( + Topology, + create_topology, + complete_topology, + per_node, + custom_topology, +) diff --git a/mango/agent/core.py b/mango/agent/core.py index fe55db82..4768f16b 100644 --- a/mango/agent/core.py +++ b/mango/agent/core.py @@ -9,6 +9,7 @@ import logging from abc import ABC from dataclasses import dataclass +from enum import Enum from typing import Any from ..util.clock import Clock @@ -23,6 +24,21 @@ class AgentAddress: aid: str +class State(Enum): + NORMAL = 0 # normal neighbor + INACTIVE = ( + 1 # neighbor link exists but link is not active (could be activated/used) + ) + BROKEN = 2 # neighbor link exists but link is not usable (can not be activated) + + +class TopologyService: + state_to_neighbors: dict[State, list] = dict() + + def neighbors(self, state: State = State.NORMAL): + return [f() for f in self.state_to_neighbors.get(state, [])] + + class AgentContext: def __init__(self, container) -> None: self._container = container @@ -69,6 +85,7 @@ def __init__(self) -> None: self.context: AgentContext = None self.scheduler: Scheduler = None self._aid = None + self._services = {} def on_start(self): """Called when container started in which the agent is contained""" @@ -344,6 +361,28 @@ async def tasks_complete(self, timeout=1): """ await self.scheduler.tasks_complete(timeout=timeout) + def service_of_type(self, type: type, default: Any = None) -> Any: + """Return the service of the type ``type`` or set the default as service and return it. + + :param type: the type of the service + :type type: type + :param default: the default if applicable + :type default: Any (optional) + :return: the service + :rtype: Any + """ + if type not in self._services: + self._services[type] = default + return self._services[type] + + def neighbors(self, state: State = State.NORMAL) -> list[AgentAddress]: + """Return the neighbors of the agent (controlled by the topology api). + + :return: the list of agent addresses filtered by state + :rtype: list[AgentAddress] + """ + return self.service_of_type(TopologyService).neighbors(state) + class Agent(ABC, AgentDelegates): """Base class for all agents.""" @@ -373,7 +412,7 @@ def suspendable_tasks(self): @suspendable_tasks.setter def suspendable_tasks(self, value: bool): - self.self.scheduler.suspendable = value + self.scheduler.suspendable = value def on_register(self): """ @@ -381,9 +420,6 @@ def on_register(self): """ def _do_register(self, container, aid): - self._check_inbox_task = asyncio.create_task(self._check_inbox()) - self._check_inbox_task.add_done_callback(self._raise_exceptions) - self._stopped = asyncio.Future() self._aid = aid self.context = AgentContext(container) self.scheduler = Scheduler( @@ -391,6 +427,13 @@ def _do_register(self, container, aid): ) self.on_register() + def _do_start(self): + self._check_inbox_task = asyncio.create_task(self._check_inbox()) + self._check_inbox_task.add_done_callback(self._raise_exceptions) + self._stopped = asyncio.Future() + + self.on_start() + def _raise_exceptions(self, fut: asyncio.Future): """ Inline function used as a callback to raise exceptions diff --git a/mango/agent/role.py b/mango/agent/role.py index 6c2698a2..7401c1bd 100644 --- a/mango/agent/role.py +++ b/mango/agent/role.py @@ -34,7 +34,8 @@ import asyncio from abc import ABC -from typing import Any, Callable +from collections.abc import Callable +from typing import Any from mango.agent.core import Agent, AgentAddress, AgentDelegates @@ -49,11 +50,11 @@ def __setitem__(self, key, newvalue): def __contains__(self, key): return hasattr(self, key) - def get(self, key): + def get(self, key, default=None): if key in self: return self[key] else: - return None + return default def update(self, data: dict): for k, v in data.items(): @@ -67,7 +68,7 @@ class Role: class RoleHandler: """Contains all roles and their models. Implements the communication between roles.""" - def __init__(self, agent_context, scheduler): + def __init__(self, scheduler): self._role_models = {} self._roles = [] self._role_to_active = {} @@ -75,7 +76,6 @@ def __init__(self, agent_context, scheduler): self._message_subs = [] self._send_msg_subs = {} self._role_event_type_to_handler = {} - self._agent_context = agent_context self._scheduler = scheduler self._data = DataContainer() @@ -185,11 +185,16 @@ def handle_message(self, content, meta: dict[str, Any]): :param content: content :param meta: meta """ - for role in self.roles: - role.handle_message(content, meta) + handle_message_found = False for role, message_condition, method, _ in self._message_subs: + # do not execute handle_message twice if role has subscription as well + if method.__name__ == "handle_message": + handle_message_found = True if self._is_role_active(role) and message_condition(content, meta): method(content, meta) + if not handle_message_found: + for role in self.roles: + role.handle_message(content, meta) def _notify_send_message_subs(self, content, receiver_addr: AgentAddress, **kwargs): for role in self._send_msg_subs: @@ -252,7 +257,6 @@ def __init__( inbox, ): super().__init__() - self._agent_context = None self._role_handler = role_handler self._aid = aid self._inbox = inbox @@ -268,7 +272,7 @@ def data(self): @property def current_timestamp(self) -> float: - return self._agent_context.current_timestamp + return self.context.current_timestamp def _get_container(self): return self._role_handler._data @@ -334,7 +338,7 @@ async def send_message( **kwargs, ): self._role_handler._notify_send_message_subs(content, receiver_addr, **kwargs) - return await self._agent_context.send_message( + return await self.context.send_message( content=content, receiver_addr=receiver_addr, sender_id=self.aid, @@ -362,14 +366,6 @@ def subscribe_event(self, role: Role, event_type: Any, handler_method: Callable) """ self._role_handler.subscribe_event(role, event_type, handler_method) - @property - def addr(self): - return self._agent_context.addr - - @property - def aid(self): - return self._aid - def deactivate(self, role) -> None: self._role_handler.deactivate(role) @@ -396,7 +392,7 @@ def __init__(self): Using the generated aid-style ("agentX") is not allowed. """ super().__init__() - self._role_handler = RoleHandler(None, None) + self._role_handler = RoleHandler(None) self._role_context = RoleContext(self._role_handler, self.aid, self.inbox) def on_start(self): @@ -406,8 +402,7 @@ def on_ready(self): self._role_context.on_ready() def on_register(self): - self._role_context._agent_context = self.context - self._role_handler._agent_context = self.context + self._role_context.context = self.context self._role_context.scheduler = self.scheduler self._role_handler._scheduler = self.scheduler self._role_context._aid = self.aid diff --git a/mango/container/core.py b/mango/container/core.py index 6eeffef0..aea994e0 100644 --- a/mango/container/core.py +++ b/mango/container/core.py @@ -298,7 +298,7 @@ async def start(self): """Start the container. It totally depends on the implementation for what is actually happening.""" for agent in self._agents.values(): - agent.on_start() + agent._do_start() def on_ready(self): for agent in self._agents.values(): diff --git a/mango/container/mp.py b/mango/container/mp.py index 35ea4623..d4602167 100644 --- a/mango/container/mp.py +++ b/mango/container/mp.py @@ -123,6 +123,9 @@ async def start_agent_loop(): agent_creator(container) process_initialized_event.set() + for agent in container._agents.values(): + agent._do_start() + container.running = True while not terminate_event.is_set(): await asyncio.sleep(WAIT_STEP) await container.shutdown() diff --git a/mango/container/tcp.py b/mango/container/tcp.py index 3e616b5a..0e360717 100644 --- a/mango/container/tcp.py +++ b/mango/container/tcp.py @@ -220,7 +220,7 @@ async def send_message( protocol_addr = receiver_addr.protocol_addr if isinstance(protocol_addr, str) and ":" in protocol_addr: protocol_addr = protocol_addr.split(":") - elif isinstance(protocol_addr, (tuple, list)) and len(protocol_addr) == 2: + elif isinstance(protocol_addr, tuple | list) and len(protocol_addr) == 2: protocol_addr = tuple(protocol_addr) else: logger.warning("Address for sending message is not valid;%s", protocol_addr) @@ -257,7 +257,7 @@ async def _send_external_message(self, addr, message, meta) -> bool: :param message: The message :return: """ - if addr is None or not isinstance(addr, (tuple, list)) or len(addr) != 2: + if addr is None or not isinstance(addr, tuple | list) or len(addr) != 2: logger.warning( "Sending external message not successful, invalid address; %s", addr, diff --git a/mango/express/topology.py b/mango/express/topology.py new file mode 100644 index 00000000..a9356041 --- /dev/null +++ b/mango/express/topology.py @@ -0,0 +1,139 @@ +from contextlib import contextmanager + +import networkx as nx + +from mango.agent.core import Agent, AgentAddress, State, TopologyService + +AGENT_NODE_KEY = "node" +STATE_EDGE_KEY = "state" + + +def _flatten_list(list): + return [x for xs in list for x in xs] + + +class AgentNode: + def __init__(self, agents: list = None) -> None: + self.agents: list[Agent] = [] if agents is None else agents + + def add(self, agent: Agent): + self.agents.append(agent) + + +class Topology: + def __init__(self, graph) -> None: + self.graph: nx.Graph = graph + + for node in self.graph.nodes: + self.graph.nodes[node][AGENT_NODE_KEY] = AgentNode() + for edge in self.graph.edges: + self.graph.edges[edge][STATE_EDGE_KEY] = State.NORMAL + + def set_edge_state(self, node_id_from: int, node_id_to: int, state: State): + self.graph.edges[node_id_from, node_id_to] = state + + def add_node(self, *agents: Agent): + id = len(self.graph) + self.graph.add_node(id, node=AgentNode(agents)) + return id + + def add_edge(self, node_from, node_to, state: State = State.NORMAL): + self.graph.add_edge(node_from, node_to, state=state) + + @property + def agents(self) -> list[Agent]: + """Return all agents controlled by the topology after + the neighborhood were injected. + + :return: the list of agents + :rtype: list[Agent] + """ + return _flatten_list( + [self.graph.nodes[node][AGENT_NODE_KEY].agents for node in self.graph.nodes] + ) + + def inject(self): + # 2nd pass, build the neighborhoods and add it to agents + for node in self.graph.nodes: + agent_node = self.graph.nodes[node][AGENT_NODE_KEY] + state_to_neighbors: dict[State, list[AgentAddress]] = dict() + + for neighbor in self.graph.neighbors(node): + state = self.graph.edges[node, neighbor][STATE_EDGE_KEY] + neighbor_addresses = state_to_neighbors.setdefault(state, []) + neighbor_addresses.extend( + [ + lambda: agent.addr + for agent in self.graph.nodes[neighbor][AGENT_NODE_KEY].agents + ] + ) + for agent in agent_node.agents: + topology_service = agent.service_of_type( + TopologyService, TopologyService() + ) + topology_service.state_to_neighbors = state_to_neighbors + + +def complete_topology(number_of_nodes: int) -> Topology: + """ + Create a fully-connected topology. + """ + graph = nx.complete_graph(number_of_nodes) + return Topology(graph) + + +def custom_topology(graph: nx.Graph) -> Topology: + """ + Create an already created custom topology base on a nx Graph. + """ + return Topology(graph) + + +@contextmanager +def create_topology(directed: bool = False): + """Create a topology, which will automatically inject the neighbors to + the participating agents. Example: + + .. code-block:: python + + agents = [TopAgent(), TopAgent(), TopAgent()] + with create_topology() as topology: + id_1 = topology.add_node(agents[0]) + id_2 = topology.add_node(agents[1]) + id_3 = topology.add_node(agents[2]) + topology.add_edge(id_1, id_2) + topology.add_edge(id_1, id_3) + + :param directed: _description_, defaults to False + :type directed: bool, optional + :yield: _description_ + :rtype: _type_ + """ + topology = Topology(nx.DiGraph() if directed else nx.Graph()) + try: + yield topology + finally: + topology.inject() + + +def per_node(topology: Topology): + """Assign agents per node of the already created topology. This method + shall be used as iterator in a for in construct. The iterator will return + nodes, which can be used to add (with node.add()) agents to the node. + + .. code-block:: python + + topology = complete_topology(3) + for node in per_node(topology): + node.add(TopAgent()) + + + :param topology: the topology + :type topology: Topology + :yield: AgentNode + :rtype: _type_ + """ + for node in topology.graph.nodes: + agent_node = topology.graph.nodes[node][AGENT_NODE_KEY] + yield agent_node + topology.inject() diff --git a/mango/messages/codecs.py b/mango/messages/codecs.py index c5ae2286..f1af688b 100644 --- a/mango/messages/codecs.py +++ b/mango/messages/codecs.py @@ -244,14 +244,14 @@ def _acl_to_proto(self, acl_message): msg.reply_by = acl_message.reply_by if acl_message.reply_by else "" msg.in_reply_to = acl_message.in_reply_to if acl_message.in_reply_to else "" - if isinstance(acl_message.sender_addr, (tuple, list)): + if isinstance(acl_message.sender_addr, tuple | list): msg.sender_addr = ( f"{acl_message.sender_addr[0]}:{acl_message.sender_addr[1]}" ) elif acl_message.sender_addr: msg.sender_addr = acl_message.sender_addr - if isinstance(acl_message.receiver_addr, (tuple, list)): + if isinstance(acl_message.receiver_addr, tuple | list): msg.receiver_addr = ( f"{acl_message.receiver_addr[0]}:{acl_message.receiver_addr[1]}" ) diff --git a/mango/messages/message.py b/mango/messages/message.py index 6dbfec61..3400cf3d 100644 --- a/mango/messages/message.py +++ b/mango/messages/message.py @@ -14,8 +14,9 @@ from enum import Enum from typing import Any -from ..messages.acl_message_pb2 import ACLMessage as ACLProto -from ..messages.mango_message_pb2 import MangoMessage as MangoMsg +from ..agent.core import AgentAddress +from .acl_message_pb2 import ACLMessage as ACLProto +from .mango_message_pb2 import MangoMessage as MangoMsg class Message(ABC): @@ -245,41 +246,42 @@ class Performatives(Enum): def create_acl( content, - receiver_addr: str | tuple[str, int], - sender_addr: str | tuple[str, int], - receiver_id: None | str = None, + receiver_addr: AgentAddress, + sender_addr: AgentAddress, acl_metadata: None | dict[str, Any] = None, is_anonymous_acl=False, ): acl_metadata = {} if acl_metadata is None else acl_metadata.copy() # analyse and complete acl_metadata - if "receiver_addr" not in acl_metadata.keys(): - acl_metadata["receiver_addr"] = receiver_addr - elif acl_metadata["receiver_addr"] != receiver_addr: + if ( + "receiver_addr" in acl_metadata.keys() + and acl_metadata["receiver_addr"] != receiver_addr.protocol_addr + ): warnings.warn( - f"The argument receiver_addr ({receiver_addr}) is not equal to " + f"The argument receiver_addr ({receiver_addr.protocol_addr}) is not equal to " f"acl_metadata['receiver_addr'] ({acl_metadata['receiver_addr']}). \ For consistency, the value in acl_metadata['receiver_addr'] " f"was overwritten with receiver_addr.", UserWarning, ) - acl_metadata["receiver_addr"] = receiver_addr - if receiver_id: - if "receiver_id" not in acl_metadata.keys(): - acl_metadata["receiver_id"] = receiver_id - elif acl_metadata["receiver_id"] != receiver_id: - warnings.warn( - f"The argument receiver_id ({receiver_id}) is not equal to " - f"acl_metadata['receiver_id'] ({acl_metadata['receiver_id']}). \ - For consistency, the value in acl_metadata['receiver_id'] " - f"was overwritten with receiver_id.", - UserWarning, - ) - acl_metadata["receiver_id"] = receiver_id + if ( + "receiver_id" in acl_metadata.keys() + and acl_metadata["receiver_id"] != receiver_addr.aid + ): + warnings.warn( + f"The argument receiver_id ({receiver_addr.aid}) is not equal to " + f"acl_metadata['receiver_id'] ({acl_metadata['receiver_id']}). \ + For consistency, the value in acl_metadata['receiver_id'] " + f"was overwritten with receiver_id.", + UserWarning, + ) + acl_metadata["receiver_addr"] = receiver_addr.protocol_addr + acl_metadata["receiver_id"] = receiver_addr.aid + # add sender_addr if not defined and not anonymous if not is_anonymous_acl: - if "sender_addr" not in acl_metadata.keys() and sender_addr is not None: - acl_metadata["sender_addr"] = sender_addr + acl_metadata["sender_addr"] = sender_addr.protocol_addr + acl_metadata["sender_id"] = sender_addr.aid message = ACLMessage() message.content = content diff --git a/mango/util/distributed_clock.py b/mango/util/distributed_clock.py index b5dd3453..b088265e 100644 --- a/mango/util/distributed_clock.py +++ b/mango/util/distributed_clock.py @@ -28,7 +28,7 @@ def handle_message(self, content: float, meta): sender = sender_addr(meta) logger.debug("clockmanager: %s from %s", content, sender) if content: - assert isinstance(content, (int, float)), f"{content} was {type(content)}" + assert isinstance(content, int | float), f"{content} was {type(content)}" self.schedules.append(content) if not self.futures[sender].done(): @@ -177,5 +177,5 @@ def respond(fut: asyncio.Future = None): t.add_done_callback(respond) else: - assert isinstance(content, (int, float)), f"{content} was {type(content)}" + assert isinstance(content, int | float), f"{content} was {type(content)}" self.scheduler.clock.set_time(content) diff --git a/mango/util/scheduling.py b/mango/util/scheduling.py index b207d202..8ec62fd4 100644 --- a/mango/util/scheduling.py +++ b/mango/util/scheduling.py @@ -10,7 +10,7 @@ from dataclasses import dataclass from multiprocessing import Manager from multiprocessing.synchronize import Event as MultiprocessingEvent -from typing import Any, List, Tuple +from typing import Any from dateutil.rrule import rrule @@ -453,12 +453,12 @@ def __init__( observable=True, ): # List of Tuples with asyncio.Future, ScheduledTask, Suspendable coro, Source - self._scheduled_tasks: List[ - Tuple[ScheduledTask, asyncio.Future, Suspendable, Any] + self._scheduled_tasks: list[ + tuple[ScheduledTask, asyncio.Future, Suspendable, Any] ] = [] self.clock = clock if clock is not None else AsyncioClock() - self._scheduled_process_tasks: List[ - Tuple[ScheduledProcessTask, Future, ScheduledProcessControl, Any] + self._scheduled_process_tasks: list[ + tuple[ScheduledProcessTask, Future, ScheduledProcessControl, Any] ] = [] self._manager = None self._num_process_parallel = num_process_parallel diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..074914ed --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,80 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "mango-agents" +version = "2.1.0" +authors = [ + { name="mango Team", email="mango@offis.de" }, +] +description = "Modular Python Agent Framework" +readme = "readme.md" +requires-python = ">=3.10" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +license = {file="LICENSE"} +dependencies = [ + "paho-mqtt>=2.1.0", + "python-dateutil>=2.9.0", + "dill>=0.3.8", + "protobuf==5.27.2", + "networkx>=3.4.1" +] + +[project.optional-dependencies] +fastjson = [ + "msgspec>=0.18.6" +] +test = [ + "pytest", + "pytest-cov", + "pytest-asyncio", + "pre-commit" +] + +[project.urls] +Homepage = "https://mango-agents.readthedocs.io" +Repository = "https://github.com/OFFIS-DAI/mango" +Issues = "https://github.com/OFFIS-DAI/mango/issues" + + +[tool.pytest.ini_options] +asyncio_default_fixture_loop_scope = "function" +markers = [ + "mqtt", +] + +[tool.coverage.run] +omit = ["tests/*"] + +[tool.ruff.lint] +# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. +# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or +# McCabe complexity (`C901`) by default. +select = ["E", "F", "G", "I", "UP", "AIR", "PIE", "PLR1714", "PLW2901", "TRY201"] +ignore = ["E501"] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = [ + "I001", # allow unsorted imports in __init__.py + "F401", # allow unused imports in __init__.py +] +"examples/*" = [ + "ARG", # allow unused arguments + "F841", # allow unused local variables +] +"tests/*" = [ + "ARG", # allow unused arguments for pytest fixtures + "E741", # allow reused variables + "F841", # allow unused local variables +] diff --git a/readme.md b/readme.md index 781231d3..5d91e1c8 100644 --- a/readme.md +++ b/readme.md @@ -11,6 +11,8 @@ ![lifecycle](https://img.shields.io/badge/lifecycle-maturing-blue.svg) [![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/OFFIS-DAI/mango/blob/development/LICENSE) [![Test mango-python](https://github.com/OFFIS-DAI/mango/actions/workflows/test-mango.yml/badge.svg)](https://github.com/OFFIS-DAI/mango/actions/workflows/test-mango.yml) +[![codecov](https://codecov.io/gh/OFFIS-DAI/mango/graph/badge.svg?token=6KVKBICGYG)](https://codecov.io/gh/OFFIS-DAI/mango) + mango (**m**odul**a**r pytho**n** a**g**ent framew**o**rk) is a python library for multi-agent systems (MAS). @@ -30,15 +32,9 @@ A detailed documentation for this project can be found at [mango-agents.readthed ## Installation -*mango* requires Python >= 3.8 and runs on Linux, OSX and Windows. - -For installation of mango you should use +For installation of mango you may use [virtualenv](https://virtualenv.pypa.io/en/latest/#) which can create isolated Python environments for different projects. -It is also recommended to install -[virtualenvwrapper](https://virtualenvwrapper.readthedocs.io/en/latest/index.html) -which makes it easier to manage different virtual environments. - Once you have created a virtual environment you can just run [pip](https://pip.pypa.io/en/stable/) to install it: $ pip install mango-agents diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 48881a92..00000000 --- a/requirements.txt +++ /dev/null @@ -1,27 +0,0 @@ -appdirs==1.4.4 -argcomplete==1.12.0 -atomicwrites==1.4.0 -attrs==20.2.0 -colorama==0.4.3 -colorlog==4.2.1 -decorator==4.4.2 -distlib==0.3.1 -filelock==3.0.12 -iniconfig==1.0.1 -more-itertools==8.5.0 -nox==2020.8.22 -packaging==24.1 -paho-mqtt==2.1.0 -pika==1.1.0 -pluggy>=0.13.1 -protobuf==5.27.2 -pyparsing==2.4.7 -pytest==8.2.2 -pytest-asyncio==0.14.0 -pyzmq==26.0.3 -toml==0.10.1 -virtualenv==20.0.31 -python-dateutil==2.9.0 -dill==0.3.6 -apipkg>=3.0.2 -six>=1.16.0 diff --git a/ruff.toml b/ruff.toml deleted file mode 100644 index a1a8efcd..00000000 --- a/ruff.toml +++ /dev/null @@ -1,23 +0,0 @@ -target-version = "py38" - -[lint] -# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. -# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or -# McCabe complexity (`C901`) by default. -select = ["E", "F", "G", "I", "UP", "AIR", "PIE", "PLR1714", "PLW2901", "TRY201"] -ignore = ["E501"] - -[lint.per-file-ignores] -"__init__.py" = [ - "I001", # allow unsorted imports in __init__.py - "F401", # allow unused imports in __init__.py -] -"examples/*" = [ - "ARG", # allow unused arguments - "F841", # allow unused local variables -] -"tests/*" = [ - "ARG", # allow unused arguments for pytest fixtures - "E741", # allow reused variables - "F841", # allow unused local variables -] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 6279a4d2..00000000 --- a/setup.cfg +++ /dev/null @@ -1,6 +0,0 @@ -[tool:pytest] - -markers = - mqtt - -asyncio_default_fixture_loop_scope = function diff --git a/setup.py b/setup.py deleted file mode 100644 index c7c76165..00000000 --- a/setup.py +++ /dev/null @@ -1,133 +0,0 @@ -#!/usr/bin/env python -# from https://github.com/navdeep-G/setup.py -# Note: To use the 'upload' functionality of this file, you must: -# $ pipenv install twine --dev - -import os -import sys -from shutil import rmtree - -from setuptools import Command, find_packages, setup - -# Package meta-data. -NAME = "mango-agents" -DESCRIPTION = "Modular Python Agent Framework" -URL = "https://github.com/OFFIS-DAI/mango" -EMAIL = "mango@offis.de" -AUTHOR = "mango Team" -REQUIRES_PYTHON = ">=3.10.0" -VERSION = "2.0.3" - -# What packages are required for this module to be executed? -REQUIRED = [ - "paho-mqtt>=2.1.0", - "python-dateutil>=2.9.0", - "dill>=0.3.8", - "protobuf>=5.27.2", -] - -# What packages are optional? -EXTRAS = {"fastjson": ["msgspec>=0.18.6"]} - - -# The rest you shouldn't have to touch too much :) -# ------------------------------------------------ -# Except, perhaps the License and Trove Classifiers! -# If you do change the License, remember to change the Trove Classifier for that! - -here = os.path.abspath(os.path.dirname(__file__)) - -# Import the README and use it as the long-description. -# Note: this will only work if 'README.md' is present in your MANIFEST.in file! -try: - with open(os.path.join(here, "readme.md"), encoding="utf-8") as f: - long_description = "\n" + f.read() -except FileNotFoundError: - long_description = DESCRIPTION - -# Load the package's __version__.py module as a dictionary. -about = {} -if not VERSION: - project_slug = NAME.lower().replace("-", "_").replace(" ", "_") - with open(os.path.join(here, project_slug, "__version__.py")) as f: - exec(f.read(), about) -else: - about["__version__"] = VERSION - - -class UploadCommand(Command): - """Support setup.py upload.""" - - description = "Build and publish the package." - user_options = [] - - @staticmethod - def status(s): - """Prints things in bold.""" - print(f"\033[1m{s}\033[0m") - - def initialize_options(self): - pass - - def finalize_options(self): - pass - - def run(self): - try: - self.status("Removing previous builds…") - rmtree(os.path.join(here, "dist")) - except OSError: - pass - - self.status("Building Source and Wheel (universal) distribution…") - os.system(f"{sys.executable} setup.py sdist bdist_wheel --universal") - - self.status("Uploading the package to PyPI via Twine…") - os.system("twine upload dist/*") - - self.status("Pushing git tags…") - os.system("git tag v{}".format(about["__version__"])) - os.system("git push --tags") - - sys.exit() - - -# Where the magic happens: -setup( - name=NAME, - version=about["__version__"], - description=DESCRIPTION, - long_description=long_description, - long_description_content_type="text/markdown", - author=AUTHOR, - author_email=EMAIL, - python_requires=REQUIRES_PYTHON, - # url=URL, - packages=find_packages(exclude=["tests", "*.tests", "*.tests.*", "tests.*"]), - # If your package is a single module, use this instead of 'packages': - # py_modules=['mypackage'], - # entry_points={ - # 'console_scripts': ['mycli=mymodule:cli'], - # }, - install_requires=REQUIRED, - extras_require=EXTRAS, - include_package_data=True, - license="MIT", - classifiers=[ - # Trove classifiers - # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers - "License :: OSI Approved :: MIT License", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy", - ], - # $ setup.py publish support. - cmdclass={ - "upload": UploadCommand, - }, -) diff --git a/tests/integration_tests/test_single_container_termination.py b/tests/integration_tests/test_single_container_termination.py index a3c6bf4d..9293e30f 100644 --- a/tests/integration_tests/test_single_container_termination.py +++ b/tests/integration_tests/test_single_container_termination.py @@ -176,6 +176,7 @@ async def test_distribute_ping_pong_tcp(): @pytest.mark.asyncio +@pytest.mark.mqtt async def test_distribute_ping_pong_mqtt(): await distribute_ping_pong_test("mqtt") @@ -186,6 +187,7 @@ async def test_distribute_ping_pong_ts_tcp(): @pytest.mark.asyncio +@pytest.mark.mqtt async def test_distribute_ping_pong_ts_mqtt(): await distribute_ping_pong_test_timestamp("mqtt") @@ -296,6 +298,7 @@ async def test_distribute_time_tcp(): @pytest.mark.asyncio +@pytest.mark.mqtt async def test_distribute_time_mqtt(): await distribute_time_test_case("mqtt") @@ -306,5 +309,6 @@ async def test_send_current_time_tcp(): @pytest.mark.asyncio +@pytest.mark.mqtt async def test_send_current_time_mqtt(): await send_current_time_test_case("mqtt") diff --git a/tests/unit_tests/core/test_agent.py b/tests/unit_tests/core/test_agent.py index 42edfbc2..fc80f262 100644 --- a/tests/unit_tests/core/test_agent.py +++ b/tests/unit_tests/core/test_agent.py @@ -33,7 +33,6 @@ async def increase_counter(): # THEN assert len(l) == 2 - await c.shutdown() @pytest.mark.asyncio @@ -62,7 +61,7 @@ async def test_send_acl_message(): async with activate(c) as c: await agent.send_message( - create_acl("", receiver_addr=agent2.addr, sender_addr=c.addr), + create_acl("", receiver_addr=agent2.addr, sender_addr=agent.addr), receiver_addr=agent2.addr, ) msg = await agent2.inbox.get() @@ -96,9 +95,27 @@ async def test_schedule_acl_message(): async with activate(c) as c: await agent.schedule_instant_message( - create_acl("", receiver_addr=agent2.addr, sender_addr=c.addr), + create_acl("", receiver_addr=agent2.addr, sender_addr=agent.addr), receiver_addr=agent2.addr, ) # THEN assert agent2.test_counter == 1 + + +def test_sync_setup_agent(): + # this test is not async and therefore does not provide a running event loop + c = create_tcp_container(addr=("127.0.0.1", 5555)) + # registration without async context should not raise "no running event loop" error + agent = c.register(MyAgent()) + agent2 = c.register(MyAgent()) + + async def run_this(c): + async with activate(c) as c: + await agent.schedule_instant_message( + create_acl("", receiver_addr=agent2.addr, sender_addr=agent.addr), + receiver_addr=agent2.addr, + ) # THEN + assert agent2.test_counter == 1 + + asyncio.run(run_this(c)) diff --git a/tests/unit_tests/core/test_container.py b/tests/unit_tests/core/test_container.py index 3464718d..fe01139f 100644 --- a/tests/unit_tests/core/test_container.py +++ b/tests/unit_tests/core/test_container.py @@ -1,7 +1,7 @@ import pytest from mango import activate, create_acl, create_tcp_container -from mango.agent.core import Agent +from mango.agent.core import Agent, AgentAddress class LooksLikeAgent: @@ -153,10 +153,9 @@ async def test_create_acl_no_modify(): common_acl_q = {} actual_acl_message = create_acl( "", - receiver_addr="", - receiver_id="", + receiver_addr=AgentAddress("", ""), acl_metadata=common_acl_q, - sender_addr=c.addr, + sender_addr=AgentAddress(c.addr, ""), ) assert "reeiver_addr" not in common_acl_q @@ -170,7 +169,10 @@ async def test_create_acl_no_modify(): async def test_create_acl_anon(): c = create_tcp_container(addr=("127.0.0.1", 5555)) actual_acl_message = create_acl( - "", receiver_addr="", receiver_id="", is_anonymous_acl=True, sender_addr=c.addr + "", + receiver_addr=AgentAddress("", ""), + is_anonymous_acl=True, + sender_addr=AgentAddress(c.addr, ""), ) assert actual_acl_message.sender_addr is None @@ -181,7 +183,10 @@ async def test_create_acl_anon(): async def test_create_acl_not_anon(): c = create_tcp_container(addr=("127.0.0.1", 5555)) actual_acl_message = create_acl( - "", receiver_addr="", receiver_id="", is_anonymous_acl=False, sender_addr=c.addr + "", + receiver_addr=AgentAddress("", ""), + is_anonymous_acl=False, + sender_addr=AgentAddress(c.addr, ""), ) assert actual_acl_message.sender_addr is not None @@ -203,8 +208,8 @@ async def test_send_message_no_copy(): agent1 = c.register(ExampleAgent()) message_to_send = Data() - await c.send_message(message_to_send, receiver_addr=agent1.addr) - await c.shutdown() + async with activate(c): + await c.send_message(message_to_send, receiver_addr=agent1.addr) assert agent1.content is message_to_send @@ -215,8 +220,8 @@ async def test_send_message_copy(): agent1 = c.register(ExampleAgent()) message_to_send = Data() - await c.send_message(message_to_send, receiver_addr=agent1.addr) - await c.shutdown() + async with activate(c): + await c.send_message(message_to_send, receiver_addr=agent1.addr) assert agent1.content is not message_to_send @@ -227,10 +232,9 @@ async def test_create_acl_diff_receiver(): with pytest.warns(UserWarning) as record: actual_acl_message = create_acl( "", - receiver_addr="A", - receiver_id="A", + receiver_addr=AgentAddress("A", "A"), acl_metadata={"receiver_id": "B", "receiver_addr": "B"}, - sender_addr=c.addr, + sender_addr=AgentAddress(c.addr, ""), is_anonymous_acl=False, ) diff --git a/tests/unit_tests/core/test_external_scheduling_container.py b/tests/unit_tests/core/test_external_scheduling_container.py index bdb2e5b7..9e74fbe3 100644 --- a/tests/unit_tests/core/test_external_scheduling_container.py +++ b/tests/unit_tests/core/test_external_scheduling_container.py @@ -1,5 +1,5 @@ import asyncio -from typing import Any, Dict +from typing import Any import pytest @@ -66,7 +66,7 @@ def __init__(self): self.current_ping = 0 self.tasks = [] - def on_register(self): + def on_ready(self): self.tasks.append(self.schedule_periodic_task(self.send_ping, delay=10)) async def send_ping(self): @@ -76,7 +76,7 @@ async def send_ping(self): ) self.current_ping += 1 - def handle_message(self, content, meta: Dict[str, Any]): + def handle_message(self, content, meta: dict[str, Any]): self.schedule_instant_task(self.sleep_and_answer(content, meta)) async def sleep_and_answer(self, content, meta): @@ -113,7 +113,7 @@ def on_register(self): async def print_cond_task_finished(self): pass - def handle_message(self, content, meta: Dict[str, Any]): + def handle_message(self, content, meta: dict[str, Any]): self.received_msg = True @@ -146,9 +146,8 @@ async def test_step_with_cond_task(): # create and send message in next step message = create_acl( content="", - receiver_addr=external_scheduling_container.addr, - receiver_id=agent_1.aid, - sender_addr=external_scheduling_container.addr, + receiver_addr=AgentAddress(external_scheduling_container.addr, agent_1.aid), + sender_addr=AgentAddress(external_scheduling_container.addr, agent_1.aid), ) encoded_msg = external_scheduling_container.codec.encode(message) print("created message") @@ -181,7 +180,7 @@ def __init__(self, final_number=3): self.no_received_msg = 0 self.final_no = final_number - def handle_message(self, content, meta: Dict[str, Any]): + def handle_message(self, content, meta: dict[str, Any]): self.no_received_msg += 1 # pretend to be really busy i = 0 @@ -204,9 +203,8 @@ async def test_send_internal_messages(): async with activate(external_scheduling_container) as c: message = create_acl( content="", - receiver_addr=external_scheduling_container.addr, - receiver_id=agent_1.aid, - sender_addr=external_scheduling_container.addr, + receiver_addr=AgentAddress(external_scheduling_container.addr, agent_1.aid), + sender_addr=AgentAddress(external_scheduling_container.addr, agent_1.aid), ) encoded_msg = external_scheduling_container.codec.encode(message) return_values = await external_scheduling_container.step( @@ -218,9 +216,9 @@ async def test_send_internal_messages(): @pytest.mark.asyncio async def test_step_with_replying_agent(): external_scheduling_container = create_ec_container(addr="external_eid_1") + reply_agent = external_scheduling_container.register(ReplyAgent()) async with activate(external_scheduling_container) as c: - reply_agent = external_scheduling_container.register(ReplyAgent()) new_acl_msg = ACLMessage() new_acl_msg.content = "hello you" new_acl_msg.receiver_addr = "external_eid_1" diff --git a/tests/unit_tests/express/__init__.py b/tests/unit_tests/express/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/express/test_api.py b/tests/unit_tests/express/test_api.py index 144357f2..563179cc 100644 --- a/tests/unit_tests/express/test_api.py +++ b/tests/unit_tests/express/test_api.py @@ -41,12 +41,23 @@ async def test_activate_pingpong(): await c.send_message( "Ping", ping_pong_agent.addr, sender_id=ping_pong_agent_two.aid ) + assert ping_pong_agent.roles[0].context.addr is not None while ping_pong_agent.roles[0].counter < 5: await asyncio.sleep(0.01) assert ping_pong_agent.roles[0].counter == 5 +@pytest.mark.asyncio +async def test_activate_rolecontext(): + container = create_tcp_container("127.0.0.1:5555") + ping_pong_agent = agent_composed_of(PingPongRole(), register_in=container) + + async with activate(container) as c: + assert ping_pong_agent.roles[0].context.addr is not None + assert ping_pong_agent.roles[0].context.context is not None + + class MyAgent(Agent): test_counter: int = 0 @@ -119,6 +130,7 @@ async def test_run_api_style_agent_with_aid(): @pytest.mark.asyncio +@pytest.mark.mqtt async def test_run_api_style_agent_with_aid_mqtt(): # GIVEN run_agent = MyAgent() @@ -138,3 +150,13 @@ async def test_run_api_style_agent_with_aid_mqtt(): assert run_agent2.test_counter == 1 assert run_agent2.aid == "my_custom_aid" assert run_agent.aid == "agent0" + + +@pytest.mark.asyncio +async def test_deactivate_suspendable(): + container = create_tcp_container("127.0.0.1:5555") + ping_pong_agent = agent_composed_of(PingPongRole(), register_in=container) + ping_pong_agent.suspendable_tasks = False + + async with activate(container) as c: + assert ping_pong_agent.scheduler.suspendable is False diff --git a/tests/unit_tests/express/test_topology.py b/tests/unit_tests/express/test_topology.py new file mode 100644 index 00000000..2206e682 --- /dev/null +++ b/tests/unit_tests/express/test_topology.py @@ -0,0 +1,54 @@ +import asyncio +from typing import Any + +import pytest + +from mango import Agent, complete_topology, create_topology, per_node, run_with_tcp + + +class TopAgent(Agent): + counter: int = 0 + + def handle_message(self, content, meta: dict[str, Any]): + self.counter += 1 + + +@pytest.mark.asyncio +async def test_run_api_style_agent(): + # GIVEN + topology = complete_topology(3) + for node in per_node(topology): + agent = TopAgent() + node.add(agent) + + # WHEN + async with run_with_tcp(1, *topology.agents): + for neighbor in topology.agents[0].neighbors(): + await topology.agents[0].send_message("hello neighbors", neighbor) + await asyncio.sleep(0.1) + + # THEN + assert topology.agents[1].counter == 1 + assert topology.agents[2].counter == 1 + + +@pytest.mark.asyncio +async def test_run_api_style_custom_topology(): + # GIVEN + agents = [TopAgent(), TopAgent(), TopAgent()] + with create_topology() as topology: + id_1 = topology.add_node(agents[0]) + id_2 = topology.add_node(agents[1]) + id_3 = topology.add_node(agents[2]) + topology.add_edge(id_1, id_2) + topology.add_edge(id_1, id_3) + + # WHEN + async with run_with_tcp(1, *agents): + for neighbor in agents[0].neighbors(): + await agents[0].send_message("hello neighbors", neighbor) + await asyncio.sleep(0.1) + + # THEN + assert agents[1].counter == 1 + assert agents[2].counter == 1 diff --git a/tests/unit_tests/role/role_test.py b/tests/unit_tests/role/role_test.py index 5c419777..ea8b3f21 100644 --- a/tests/unit_tests/role/role_test.py +++ b/tests/unit_tests/role/role_test.py @@ -21,7 +21,7 @@ def on_change_model(self, model): def test_subscription(): # GIVEN - role_handler = RoleHandler(None, None) + role_handler = RoleHandler(None) ex_role = SubRole() ex_role2 = SubRole() role_model = role_handler.get_or_create_model(RoleModel) @@ -40,7 +40,7 @@ def test_subscription(): def test_subscription_deactivated(): # GIVEN - role_handler = RoleHandler(None, Scheduler()) + role_handler = RoleHandler(Scheduler()) ex_role = SubRole() ex_role2 = SubRole() role_model = role_handler.get_or_create_model(RoleModel) @@ -60,7 +60,7 @@ def test_subscription_deactivated(): def test_no_subscription_update(): # GIVEN - role_handler = RoleHandler(None, None) + role_handler = RoleHandler(None) ex_role = SubRole() role_model = role_handler.get_or_create_model(RoleModel) @@ -74,7 +74,7 @@ def test_no_subscription_update(): def test_append_message_subs(): # GIVEN - role_handler = RoleHandler(None, None) + role_handler = RoleHandler(None) test_role = SubRole() # WHEN @@ -124,7 +124,7 @@ def setup(self) -> None: def test_emit_event(): # GIVEN - role_handler = RoleHandler(None, None) + role_handler = RoleHandler(None) context = RoleContext(role_handler, None, None) ex_role = SubRole() ex_role2 = RoleHandlingEvents() diff --git a/tests/unit_tests/role_agent_test.py b/tests/unit_tests/role_agent_test.py index 3fde9bc3..47bbee14 100644 --- a/tests/unit_tests/role_agent_test.py +++ b/tests/unit_tests/role_agent_test.py @@ -1,6 +1,6 @@ import asyncio import datetime -from typing import Any, Dict +from typing import Any import pytest @@ -15,10 +15,10 @@ def setup(self): self, self.react_handle_message, self.is_applicable ) - def react_handle_message(self, content, meta: Dict[str, Any]) -> None: + def react_handle_message(self, content, meta: dict[str, Any]) -> None: pass - def is_applicable(self, content, meta: Dict[str, Any]) -> bool: + def is_applicable(self, content, meta: dict[str, Any]) -> bool: return True @@ -27,7 +27,7 @@ def __init__(self): super().__init__() self.sending_tasks = [] - def react_handle_message(self, content, meta: Dict[str, Any]): + def react_handle_message(self, content, meta: dict[str, Any]): assert "sender_addr" in meta.keys() and "sender_id" in meta.keys() # send back pong, providing your own details @@ -47,7 +47,7 @@ def __init__(self, target, expect_no_answer=False): self.target = target self._expect_no_answer = expect_no_answer - def react_handle_message(self, content, meta: Dict[str, Any]): + def react_handle_message(self, content, meta: dict[str, Any]): assert "sender_addr" in meta.keys() and "sender_id" in meta.keys() sender = sender_addr(meta) assert sender in self.open_ping_requests.keys() @@ -109,11 +109,21 @@ def setup(self): class SampleRole(Role): def __init__(self): self.setup_called = False + self.messages = 0 def setup(self): assert self.context is not None self.setup_called = True + def handle_message(self, content: Any, meta: dict): + self.messages += 1 + + +class SampleSubRole(SampleRole): + def setup(self): + super().setup() + self.context.subscribe_message(self, self.handle_message, lambda c, m: True) + @pytest.mark.asyncio @pytest.mark.parametrize( @@ -220,3 +230,39 @@ async def test_role_add_remove_context(): agent.remove_role(role) assert len(agent.roles) == 0 + + +@pytest.mark.asyncio +async def test_role_addr(): + c = create_tcp_container(addr=("127.0.0.1", 5555)) + agent = c.register(RoleAgent()) + role = SampleRole() + agent.add_role(role) + + assert role.context.addr == agent.addr + + +@pytest.mark.asyncio +async def test_role_register_after_agent_register(): + c = create_tcp_container(addr=("127.0.0.1", 5555)) + agent = c.register(RoleAgent()) + role = SampleRole() + agent.add_role(role) + + async with activate(c): + await role.context.send_message("", role.context.addr) + + assert role.messages == 1 + + +@pytest.mark.asyncio +async def test_role_with_message_handler(): + c = create_tcp_container(addr=("127.0.0.1", 5555)) + agent = c.register(RoleAgent()) + role = SampleSubRole() + agent.add_role(role) + + async with activate(c): + await role.context.send_message("", role.context.addr) + + assert role.messages == 1