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 4cfc2ebf..396eae7a 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.""" diff --git a/mango/agent/role.py b/mango/agent/role.py index 16279ab9..9fbe153c 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 @@ -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() @@ -252,7 +252,6 @@ def __init__( inbox, ): super().__init__() - self._agent_context = None self._role_handler = role_handler self._aid = aid self._inbox = inbox @@ -266,17 +265,9 @@ def data(self): """ return self._get_container() - @property - def context(self) -> float: - return self._agent_context - - @context.setter - def context(self, value: bool): - pass - @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 @@ -342,7 +333,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, @@ -396,7 +387,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 +397,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/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/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..5e8f75ab --- /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/unit_tests/core/test_external_scheduling_container.py b/tests/unit_tests/core/test_external_scheduling_container.py index 2efe2768..9301d8c0 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 @@ -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 @@ -180,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 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_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..f7408108 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() @@ -114,6 +114,9 @@ def setup(self): assert self.context is not None self.setup_called = True + def handle_message(self, content: Any, meta: dict): + self.messages = 1 + @pytest.mark.asyncio @pytest.mark.parametrize( @@ -220,3 +223,26 @@ 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