Compare commits

...

13 Commits

Author SHA1 Message Date
Stanislav Hejny 07f6f4569c feat/#63 - add iterator instead of RabbitMQ callback
Unit test coverage / pytest (pull_request) Failing after 1m49s
Build and Publish Docker Image / build-and-push (push) Successful in 2m29s
Unit test coverage / pytest (push) Failing after 1m45s
/ build-and-push (push) Successful in 1m52s
CI for pypl publish / publish-lib (push) Successful in 1m44s
2025-07-27 09:50:25 +01:00
Stanislav Hejny 66132db13a feat/#63 - cleverswarm unidirectional command
Unit test coverage / pytest (pull_request) Successful in 1m38s
Unit test coverage / pytest (push) Successful in 1m52s
CI for pypl publish / publish-lib (push) Successful in 2m7s
/ build-and-push (push) Successful in 2m20s
Build and Publish Docker Image / build-and-push (push) Successful in 2m32s
2025-07-26 15:41:47 +01:00
Stanislav Hejny 9984501fee feat/#63 - cleverswarm unidirectional command
Unit test coverage / pytest (push) Failing after 1m46s
/ build-and-push (push) Successful in 1m36s
Unit test coverage / pytest (pull_request) Failing after 1m15s
2025-07-26 15:30:12 +01:00
Stanislav Hejny 41fbe46129 feat/#63 - cleverswarm unidirectional command
Unit test coverage / pytest (push) Has been cancelled
/ build-and-push (push) Has been cancelled
Unit test coverage / pytest (pull_request) Failing after 1m51s
2025-07-26 15:29:07 +01:00
Stanislav Hejny 9491517696 feat/#63 - fix the send_command and add unit tests
Unit test coverage / pytest (pull_request) Successful in 1m15s
Unit test coverage / pytest (push) Failing after 1m50s
CI for pypl publish / publish-lib (push) Successful in 1m57s
/ build-and-push (push) Successful in 1m59s
Build and Publish Docker Image / build-and-push (push) Successful in 2m12s
2025-07-24 10:05:36 +01:00
Stanislav Hejny 4985344e5e test: add package_name parameter to invoke_command calls in test_rpc.py
Co-authored-by: aider (openrouter/openai/o3-mini-high) <aider@aider.chat>
2025-07-24 08:16:42 +01:00
Stanislav Hejny f3be2fbfea test: update tests to use invoke_command directly 2025-07-24 08:16:41 +01:00
Stanislav Hejny ad49cb2836 test: convert demo script to unittest framework in test_rpc.py
Co-authored-by: aider (openrouter/openai/o3-mini-high) <aider@aider.chat>
2025-07-24 08:08:00 +01:00
Stanislav Hejny 2b20c9a0dc test: add tests for RPC command invocation and error handling 2025-07-24 08:07:57 +01:00
Stanislav Hejny 982c72205a Merge branch 'feat/61-multi-user-support' into develop
Unit test coverage / pytest (push) Successful in 2m16s
/ build-and-push (push) Successful in 2m16s
CI for pypl publish / publish-lib (push) Failing after 2m15s
Build and Publish Docker Image / build-and-push (push) Successful in 2m34s
2025-07-23 19:47:31 +01:00
Stanislav Hejny c3346bbe7a Merge branch 'feat-58-backpressure-reactive-streams' into develop
Unit test coverage / pytest (push) Successful in 1m58s
CI for pypl publish / publish-lib (push) Successful in 2m4s
/ build-and-push (push) Successful in 2m22s
Build and Publish Docker Image / build-and-push (push) Successful in 2m58s
2025-07-23 18:41:51 +01:00
hurui200320 80a55f9894 ci: fix docker missing issue
Unit test coverage / pytest (push) Successful in 2m0s
/ build-and-push (push) Successful in 2m4s
Build and Publish Docker Image / build-and-push (push) Successful in 3m5s
Unit test coverage / pytest (pull_request) Successful in 1m7s
base: node:lts-bookworm (debian 12)
2025-07-21 18:29:03 +08:00
Stanislav Hejny d8ab0b861d feat/#58 - fix unit test and deployment pipeline
Unit test coverage / pytest (push) Successful in 1m55s
Unit test coverage / pytest (pull_request) Successful in 1m52s
/ build-and-push (push) Successful in 1m58s
Build and Publish Docker Image / build-and-push (push) Failing after 5m12s
2025-07-21 10:52:46 +01:00
20 changed files with 640 additions and 126 deletions
+15
View File
@@ -37,6 +37,21 @@ jobs:
with: with:
ref: ${{ github.ref_name }} ref: ${{ github.ref_name }}
# setup docker
- name: set up docker cli
run: |
apt-get update
apt-get install ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
tee /etc/apt/sources.list.d/docker.list > /dev/null
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v2
+24 -19
View File
@@ -78,6 +78,7 @@ class BackpressureHandler:
self.current_availability = 1 self.current_availability = 1
self.max_load = 1 self.max_load = 1
self.current_load = 0 self.current_load = 0
self.idle_state_detected_time = 0
def increase_current_load(self): def increase_current_load(self):
"""Increase the number of parallel executions, or current_availability load""" """Increase the number of parallel executions, or current_availability load"""
@@ -146,12 +147,12 @@ class BackpressureHandler:
current_time = time.time() current_time = time.time()
last_event_delta: float = current_time - self.last_backpressure_event_time last_event_delta: float = current_time - self.last_backpressure_event_time
logging_info( # logging_info(
"Backpressure: Check conditions, current_availability=%s, max=%s, last_activity=%s", # "Backpressure: Check conditions, current_availability=%s, max=%s, last_activity=%s",
self.current_availability, # self.current_availability,
self.max_availability, # self.max_availability,
last_event_delta, # last_event_delta,
) # )
# Check for OVERLOAD condition - low availability (less than 20% of maximum) # Check for OVERLOAD condition - low availability (less than 20% of maximum)
if self.current_availability <= round(0.1 * self.max_availability): if self.current_availability <= round(0.1 * self.max_availability):
@@ -171,7 +172,7 @@ class BackpressureHandler:
idle_duration = self.config.backpressure.idle_duration idle_duration = self.config.backpressure.idle_duration
# Check if service has been idle for longer than the configured duration # Check if service has been idle for longer than the configured duration
if ( if (
last_event_delta > idle_duration current_time - self.idle_state_detected_time > idle_duration
and self.last_backpressure_event == ScalingRequestAlert.IDLE and self.last_backpressure_event == ScalingRequestAlert.IDLE
): ):
logging_info( logging_info(
@@ -184,19 +185,25 @@ class BackpressureHandler:
await self._handle_backpressure_idle_event() await self._handle_backpressure_idle_event()
# update / reset time-window so that the IDLE is not sent too often # update / reset time-window so that the IDLE is not sent too often
self.last_backpressure_event_time = current_time self.last_backpressure_event_time = current_time
self.idle_state_detected_time = current_time
# send IDLE even only once
self.last_backpressure_event = ScalingRequestAlert.UPDATE
else: else:
# Don't send IDLE right away when the availability increases, but wait for a while, send UPDATE instead # Don't send IDLE right away when the availability increases, but wait for a while, send UPDATE instead
self.last_backpressure_event = ScalingRequestAlert.IDLE self.last_backpressure_event = ScalingRequestAlert.IDLE
_scaling_request: ScaleRequestV1 = ScaleRequestV1( if current_time - self.idle_state_detected_time > idle_duration:
self.swarm_service_id, self.idle_state_detected_time = current_time
self.swarm_task_id, if last_event_delta > self.config.backpressure.time_window:
self.max_availability, _scaling_request: ScaleRequestV1 = ScaleRequestV1(
self.current_availability, # Current availability is passed directly self.swarm_service_id,
ScalingRequestAlert.UPDATE, self.swarm_task_id,
) self.max_availability,
# Address the message to any adapter capable of supporting BACKPRESSURE request self.current_availability, # Current availability is passed directly
await self.publish_backpressure_request(_scaling_request) ScalingRequestAlert.UPDATE,
self.last_backpressure_event_time = current_time )
# Address the message to any adapter capable of supporting BACKPRESSURE request
await self.publish_backpressure_request(_scaling_request)
self.last_backpressure_event_time = current_time
# If neither OVERLOAD nor IDLE, and it's time for an update, send UPDATE event # If neither OVERLOAD nor IDLE, and it's time for an update, send UPDATE event
elif last_event_delta > self.config.backpressure.time_window: elif last_event_delta > self.config.backpressure.time_window:
@@ -212,8 +219,6 @@ class BackpressureHandler:
self.last_backpressure_event_time = current_time self.last_backpressure_event_time = current_time
self.last_backpressure_event = ScalingRequestAlert.UPDATE self.last_backpressure_event = ScalingRequestAlert.UPDATE
self.update_last_backpressure_event_time()
async def _backpressure_monitor(self): async def _backpressure_monitor(self):
"""Periodically monitor the backpressure conditions and trigger events accordingly""" """Periodically monitor the backpressure conditions and trigger events accordingly"""
_monitor_interval = 0.5 # Monitor every 500ms _monitor_interval = 0.5 # Monitor every 500ms
+41 -17
View File
@@ -113,6 +113,25 @@ class DataMessageFactory:
payload, payload,
) )
def safe_rsplit(self, fq_method_name, default="_"):
"""
Safely split a fully qualified method name into package, class, and method
Args:
fq_method_name (str): Fully qualified method name
default (str, optional): Default value for missing tokens. Defaults to ''.
Returns:
tuple: (package, class_name, method)
"""
if not fq_method_name:
return default, default, default
parts = fq_method_name.rsplit(".", 2)
# Pad with default values if insufficient tokens
while len(parts) < 3:
parts.insert(0, default)
return tuple(parts)
def create_rpc_message( def create_rpc_message(
self, self,
fq_method_name: str, fq_method_name: str,
@@ -125,9 +144,14 @@ class DataMessageFactory:
:param args: arguments whose serialized form will form the request body :param args: arguments whose serialized form will form the request body
:return: DataMessage object :return: DataMessage object
""" """
package, class_name, method = fq_method_name.rsplit(".", 2) package, class_name, method = self.safe_rsplit(fq_method_name)
headers = {"Content-Type": ["application/octet-stream"], "Return-Address": [swarm_id]} headers = {"Content-Type": ["application/octet-stream"], "Return-Address": [swarm_id]}
trace_info = {} trace_info = {}
body = python_type_to_json(args).encode(encoding="utf-8") if args else b""
print(
f"Creating RPC message for method: {fq_method_name}, args: {args} body: {body.decode('utf-8') if body else 'None'}"
)
return DataMessage( return DataMessage(
DataMessageMagicByte.RPC_REQUEST.value, DataMessageMagicByte.RPC_REQUEST.value,
self.generate_snowflake_id(), self.generate_snowflake_id(),
@@ -136,7 +160,7 @@ class DataMessageFactory:
method, method,
headers, headers,
trace_info, trace_info,
python_type_to_json(args) if args else b"", body,
) )
@staticmethod @staticmethod
@@ -195,13 +219,13 @@ class DataMessageFactory:
header_length = 1 + len(id_bytes) + len(prolog) header_length = 1 + len(id_bytes) + len(prolog)
prolog_len = header_length.to_bytes(2, byteorder="big") prolog_len = header_length.to_bytes(2, byteorder="big")
msg_length = 2 + header_length + len(message.base64body()) msg_length = 2 + header_length + len(message.base64body())
with BytesIO(bytearray(msg_length)) as baos: baos = BytesIO(bytearray(msg_length))
baos.write(prolog_len) baos.write(prolog_len)
baos.write(DataMessageMagicByte.HTTP_REQUEST.value.encode("utf-8")) baos.write(message.magic().encode("utf-8"))
baos.write(id_bytes) baos.write(id_bytes)
baos.write(prolog) baos.write(prolog)
baos.write(message.base64body()) baos.write(message.base64body())
return baos.getvalue() return baos.getvalue()
@staticmethod @staticmethod
def from_bytes(input_bytes: bytes) -> DataMessage: def from_bytes(input_bytes: bytes) -> DataMessage:
@@ -214,7 +238,7 @@ class DataMessageFactory:
metadata = input_bytes[2 : prolog_len + 2].decode() metadata = input_bytes[2 : prolog_len + 2].decode()
pattern = re.compile( pattern = re.compile(
f"{DataMessageMagicByte.HTTP_REQUEST.value}" r"([AR])"
r"([0-9A-Fa-f]+)\|" r"([0-9A-Fa-f]+)\|"
r"m=([^|]*)\|" r"m=([^|]*)\|"
r"d=([^|]*)\|" r"d=([^|]*)\|"
@@ -230,19 +254,19 @@ class DataMessageFactory:
f"Input string [{metadata}] does not match pattern: {pattern.pattern}" f"Input string [{metadata}] does not match pattern: {pattern.pattern}"
) )
id_str = match.group(1) id_str = match.group(2)
method = match.group(2) method = match.group(3)
domain = match.group(3) domain = match.group(4)
path = match.group(4) path = match.group(5)
headers_str = match.group(5) headers_str = match.group(6)
trace_info_str = match.group(6) trace_info_str = match.group(7)
body_b64 = input_bytes[prolog_len + 2 :] body_b64 = input_bytes[prolog_len + 2 :]
headers = parse_map_list_string(headers_str) headers = parse_map_list_string(headers_str)
trace_info = parse_map_string(trace_info_str) trace_info = parse_map_string(trace_info_str)
return DataMessage( return DataMessage(
DataMessageMagicByte.HTTP_REQUEST.value, match.group(1), # Magic byte (R for RPC, A for HTTP)
SnowflakeId.from_hex(id_str), SnowflakeId.from_hex(id_str),
method, method,
domain, domain,
+7 -3
View File
@@ -39,10 +39,14 @@ def map_with_list_as_string(map_: Dict[str, List[str]]) -> str:
def parse_map_string(input_str: str) -> Dict[str, str]: def parse_map_string(input_str: str) -> Dict[str, str]:
map_ = {} map_ = {}
if input_str.startswith("{") and input_str.endswith("}"): if input_str.startswith("{") and input_str.endswith("}"):
for entry in input_str[1:-1].split(","): for entry in str(input_str[1:-1]).replace("\n", "").split(","):
if len(entry) > 1: if len(entry) > 1:
key, value = entry.split("=") if entry.startswith('"'):
map_[key] = value key, value = entry.split(":")
map_[key.strip('"')] = value.strip('"')
else:
key, value = entry.split("=")
map_[key] = value
return map_ return map_
+5
View File
@@ -43,6 +43,11 @@ class AMQAdapter:
self.PREFIX + "default-backpressure-enabled", self.PREFIX + "default-backpressure-enabled",
fallback=False, fallback=False,
) )
self.callbacks_enabled = config.getboolean(
"CleverMicro-AMQ",
self.PREFIX + "callbacks-enabled",
fallback=False,
)
# these 2 shall be provided by Docker swarm, format is a random alphanumeric string # these 2 shall be provided by Docker swarm, format is a random alphanumeric string
self.swarm_service_id = os.getenv("SWARM_SERVICE_ID", default="clever-amqp-service") self.swarm_service_id = os.getenv("SWARM_SERVICE_ID", default="clever-amqp-service")
self.swarm_task_id = os.getenv("SWARM_TASK_ID", default="python-adapter") self.swarm_task_id = os.getenv("SWARM_TASK_ID", default="python-adapter")
+1
View File
@@ -20,6 +20,7 @@ cm.amq-adapter.consul-port=8500
cm.amq-adapter.consul-max-retries=5 cm.amq-adapter.consul-max-retries=5
cm.amq-adapter.consul-initial-retry-delay=0.2 cm.amq-adapter.consul-initial-retry-delay=0.2
cm.amq-adapter.consul-id-generator-key=services/ids/counter cm.amq-adapter.consul-id-generator-key=services/ids/counter
cm.amq-adapter.callbacks-enabled=False
# CleverMicro AMQ Adapter dispatcher, settings related to RabbitMQ connection and optional REST forwarding # CleverMicro AMQ Adapter dispatcher, settings related to RabbitMQ connection and optional REST forwarding
cm.dispatch.use-dlq=true cm.dispatch.use-dlq=true
+19 -9
View File
@@ -1,4 +1,5 @@
import logging import logging
from typing import Optional
import aio_pika import aio_pika
from aio_pika.abc import AbstractRobustExchange, AbstractRobustQueue from aio_pika.abc import AbstractRobustExchange, AbstractRobustQueue
@@ -95,19 +96,25 @@ class RabbitMQClient:
route.queue if route.queue else self.router.get_consume_queue_name() route.queue if route.queue else self.router.get_consume_queue_name()
) )
logging_info("RabbitMQClient register route: [%s]", route) logging_info("RabbitMQClient register route: [%s]", route)
queue: AbstractRobustQueue = await self.channel.declare_queue( if self.amq_configuration.amq_adapter.callbacks_enabled:
name=queue_name, queue: Optional[AbstractRobustQueue] = await self.channel.declare_queue(
durable=True, name=queue_name,
exclusive=False, durable=True,
auto_delete=False, exclusive=False,
arguments=queue_args, auto_delete=False,
) arguments=queue_args,
)
else:
queue = None
_exchange_name: str = route.exchange if route.exchange else AMQ_RPC_EXCHANGE _exchange_name: str = route.exchange if route.exchange else AMQ_RPC_EXCHANGE
exchange: AbstractRobustExchange = await self.channel.declare_exchange( exchange: AbstractRobustExchange = await self.channel.declare_exchange(
name=_exchange_name, type=ExchangeType.topic name=_exchange_name, type=ExchangeType.topic
) )
await queue.bind(exchange, route.key) if self.amq_configuration.amq_adapter.callbacks_enabled:
_c_tag = await queue.consume(callback, no_ack=False) await queue.bind(exchange, route.key)
_c_tag = await queue.consume(callback, no_ack=False)
else:
_c_tag = None
# Register the route with the router and publish its detail to other adapters # Register the route with the router and publish its detail to other adapters
await self.router.register_route( await self.router.register_route(
AMQRoute( AMQRoute(
@@ -144,6 +151,9 @@ class RabbitMQClient:
Set up the RabbitMQ connection Set up the RabbitMQ connection
:return: nothing, it applies changes to instance variables :return: nothing, it applies changes to instance variables
""" """
logging.info(
f"RabbitMQClient.setupAMQ, connecting to RabbitMQ at {self.amq_configuration.dispatch.amq_host}:{self.amq_configuration.dispatch.amq_port}, loop={loop}"
)
self.connection = await aio_pika.connect_robust( self.connection = await aio_pika.connect_robust(
host=self.amq_configuration.dispatch.amq_host, host=self.amq_configuration.dispatch.amq_host,
port=self.amq_configuration.dispatch.amq_port, port=self.amq_configuration.dispatch.amq_port,
+6 -1
View File
@@ -50,7 +50,12 @@ class RouterBase:
return self.route_database.find_route(message.domain(), message.path()) return self.route_database.find_route(message.domain(), message.path())
def find_route_by_service(self, service_name: str) -> Optional[AMQRoute]: def find_route_by_service(self, service_name: str) -> Optional[AMQRoute]:
return self.route_database.find_route_by_service(service_name) _route: Optional[AMQRoute] = self.route_database.find_route_by_service(service_name)
if _route is None:
for route in self.own_routes:
if route.component_name == service_name:
_route = route
return _route
def add_routes(self, message: str) -> None: def add_routes(self, message: str) -> None:
try: try:
+176 -12
View File
@@ -1,9 +1,11 @@
import asyncio import asyncio
import importlib
import inspect
import os import os
import threading import threading
import time import time
from asyncio import AbstractEventLoop, Future from asyncio import AbstractEventLoop, Future
from typing import Dict from typing import Any, Dict
from aio_pika import Message from aio_pika import Message
from aio_pika.abc import AbstractIncomingMessage, AbstractRobustExchange from aio_pika.abc import AbstractIncomingMessage, AbstractRobustExchange
@@ -18,7 +20,7 @@ from amqp.adapter.data_message_factory import DataMessageFactory
from amqp.adapter.data_response_factory import DataResponseFactory from amqp.adapter.data_response_factory import DataResponseFactory
from amqp.adapter.file_handler import FileHandler, StreamingFileHandler from amqp.adapter.file_handler import FileHandler, StreamingFileHandler
from amqp.adapter.logging_utils import logging_debug, logging_error, logging_info from amqp.adapter.logging_utils import logging_debug, logging_error, logging_info
from amqp.adapter.serializer import map_as_string from amqp.adapter.serializer import map_as_string, parse_map_string
from amqp.adapter.service_message_factory import ServiceMessageFactory from amqp.adapter.service_message_factory import ServiceMessageFactory
from amqp.config.amq_configuration import AMQConfiguration from amqp.config.amq_configuration import AMQConfiguration
from amqp.model.model import ( from amqp.model.model import (
@@ -28,6 +30,7 @@ from amqp.model.model import (
MultipartDataMessage, MultipartDataMessage,
) )
from amqp.rabbitmq.rabbit_mq_client import RabbitMQClient from amqp.rabbitmq.rabbit_mq_client import RabbitMQClient
from amqp.router.utils import sanitize_as_rabbitmq_name
def collect_trace_info(): def collect_trace_info():
@@ -46,6 +49,75 @@ def set_text(carrier, key, value):
carrier[key] = value carrier[key] = value
async def invoke_command(
package_name: str, class_name: str, function_name: str, msg_id: str = "", kwargs=None
) -> Any:
if kwargs is None:
kwargs = {}
try:
# 1. Dynamically import the package
logging_info(f"Attempting to import package: {package_name}")
package = importlib.import_module(package_name)
logging_info(f"Package '{package_name}' imported successfully.")
target_callable = None
if class_name == "__global__":
# 2. Handle global function invocation
logging_info(f"Attempting to find global function: {function_name} in {package_name}")
target_callable = getattr(package, function_name)
logging_info(f"Global function '{function_name}' found.")
else:
# 3. Handle class method invocation
logging_info(f"Attempting to find class: {class_name} in {package_name}")
target_class = getattr(package, class_name)
logging_info(f"Class '{class_name}' found. Instantiating...")
# Instantiate the class. Assuming a default constructor or one that doesn't
# require arguments from kwargs for initialization.
# If the class __init__ needs specific arguments, this part might need adjustment
# based on how those arguments are provided.
instance = target_class()
logging_info(f"Instance of '{class_name}' created.")
logging_info(f"Attempting to find method: {function_name} in class {class_name}")
target_callable = getattr(instance, function_name)
logging_info(f"Method '{function_name}' found.")
# 4. Invoke the function/method
logging_info(f"Invoking '{function_name}' with arguments: {kwargs}")
if inspect.iscoroutinefunction(target_callable):
result = await target_callable(**kwargs)
logging_info(f"Asynchronous function '{function_name}' invoked. Result: {result}")
else:
result = target_callable(**kwargs)
logging_info(f"Synchronous function '{function_name}' invoked. Result: {result}")
response: DataResponse = DataResponseFactory.of(
id=msg_id,
response_code=200,
content_type="text/plain",
body=result.encode("utf-8"),
trace_info=collect_trace_info(),
error=None,
error_cause=None,
)
response._magic = DataMessageMagicByte.RPC_REQUEST.value
return response
except ModuleNotFoundError as e:
logging_error(f"Error: Package '{package_name}' not found. {e}")
raise
except AttributeError as e:
logging_error(
f"Error: Class '{class_name}' or function/method '{function_name}' not found in '{package_name}'. {e}"
)
raise
except Exception as e:
logging_error(f"An unexpected error occurred during invocation: {e}")
raise
class DataMessageHandler: class DataMessageHandler:
def __init__( def __init__(
self, self,
@@ -98,6 +170,8 @@ class DataMessageHandler:
self.ensure_trace_info(amq_message) self.ensure_trace_info(amq_message)
if amq_message.magic() == DataMessageMagicByte.HTTP_REQUEST.value: if amq_message.magic() == DataMessageMagicByte.HTTP_REQUEST.value:
await self.run_http_request_magic(message, amq_message, self.loop) await self.run_http_request_magic(message, amq_message, self.loop)
elif amq_message.magic() == DataMessageMagicByte.RPC_REQUEST.value:
await self.run_rpc_request(message, amq_message, self.loop)
else: else:
logging_error( logging_error(
"Unknown MAGIC value: [%s], don't know how to interpret the message", "Unknown MAGIC value: [%s], don't know how to interpret the message",
@@ -158,6 +232,85 @@ class DataMessageHandler:
self.backpressure_handler.decrease_current_load() self.backpressure_handler.decrease_current_load()
async def run_rpc_request(
self,
message: AbstractIncomingMessage,
amq_message: DataMessage,
loop: AbstractEventLoop,
) -> None:
"""
Synchronous version of on_message method. This is a wrapper around the async on_message method.
It also counts parallel executions and handles backpressure.
:param message: RabbitMQ raw message
:param amq_message: DataMessage
:param loop: Event loop
:return: DataResponse
"""
# Increment the counter for parallel executions and check resource availability
self.backpressure_handler.increase_current_load()
await self.backpressure_handler.check_overload_condition()
logging_info(
f"PARALLEL: Current Capacity [{self.backpressure_handler.current_availability}] of [{self.backpressure_handler.max_availability}]"
)
if message.reply_to:
self.reply_to = message.reply_to
try:
_a_message = AMQMessage(amq_message, self.rabbit_mq_client.connection)
_response: DataResponse = await self.on_rpc(_a_message)
logging_debug(f"Result in thread: {_response}")
try:
await self.send_reply(message.reply_to, _response)
except Exception as e:
logging_error(f"Processing message: {e}")
asyncio.run_coroutine_threadsafe(message.nack(requeue=False), loop=loop)
if isinstance(amq_message, MultipartDataMessage):
# remove the downloaded files from the output_dir
for file_id, filename in amq_message.extra_data.items():
try:
logging_info(
f"[{message.delivery_tag}] Deleting file: {file_id} in {filename}"
)
os.remove(filename)
except OSError as e:
logging_error(f"Deleting file {filename}: {e}")
except Exception as e:
logging_error(f"Request handler: {e}")
self.backpressure_handler.decrease_current_load()
async def on_rpc(self, a_message: AMQMessage) -> DataResponse:
"""
Dynamically invokes a function or a class method from a specified package.
Args:
a_message (AMQMessage): The message containing the package, class, and function details.
Args encoded in the AMQMessage:
package_name (str): The name of the Python package to import.
class_name (str): The name of the class within the package.
Use '__global__' if the function_name refers to a global function
in the package.
function_name (str): The name of the function or method to invoke.
kwargs (Dict[str, Any]): A dictionary of keyword arguments to pass to the function/method (in the body).
Returns:
Any: The return value of the invoked function or method.
Raises:
ModuleNotFoundError: If the specified package cannot be imported.
AttributeError: If the specified class or function/method does not exist.
Exception: For any other errors during invocation.
"""
package_name: str = a_message.method()
class_name: str = a_message.domain()
function_name: str = a_message.path()
kwargs: Dict[str, Any] = parse_map_string(a_message._base64body.decode("utf-8"))
return await invoke_command(
package_name, class_name, function_name, a_message.id().as_string(), kwargs
)
async def reconstitute_data_message( async def reconstitute_data_message(
self, message: AbstractIncomingMessage self, message: AbstractIncomingMessage
) -> DataMessage | None: ) -> DataMessage | None:
@@ -318,7 +471,9 @@ class DataMessageHandler:
name=rpc_exchange_name, type=ExchangeType.topic name=rpc_exchange_name, type=ExchangeType.topic
) )
# Publish the message to the exchange with the component name as the routing key # Publish the message to the exchange with the component name as the routing key
await exchange.publish(message=pika_message, routing_key=route.component_name) await exchange.publish(
message=pika_message, routing_key=sanitize_as_rabbitmq_name(route.component_name)
)
logging_debug( logging_debug(
"###### RPC Request Published to exchg:(%s) routing_key=%s, msgId=%s", "###### RPC Request Published to exchg:(%s) routing_key=%s, msgId=%s",
@@ -338,16 +493,23 @@ class DataMessageHandler:
:param message: DataMessage to be sent :param message: DataMessage to be sent
:return: Future that will be completed with the response payload :return: Future that will be completed with the response payload
""" """
rmq_body = DataMessageFactory.serialize(message)
cid = message.id().as_string()
ctype = (
message.content_type()[0]
if isinstance(message.content_type(), list)
else message.content_type()
)
print(
f"Sending RPC command to route: {route}, message ID: {cid} with body: {rmq_body} correlation ID: {cid}, content type: {ctype}"
)
# Create a Pika message with the correlation ID set to the message ID # Create a Pika message with the correlation ID set to the message ID
pika_message = Message( pika_message = Message(
body=DataMessageFactory.serialize(message), body=rmq_body,
correlation_id=message.id().as_string(), correlation_id=cid,
content_type=( content_type=ctype,
message.content_type()[0]
if isinstance(message.content_type(), list)
else message.content_type()
),
) )
print(f"SENDING RPC COMMAND: {pika_message.body} with ID: {message.id().as_string()}")
# Get the exchange from the route # Get the exchange from the route
rpc_exchange_name = route.exchange rpc_exchange_name = route.exchange
@@ -355,9 +517,11 @@ class DataMessageHandler:
name=rpc_exchange_name, type=ExchangeType.topic name=rpc_exchange_name, type=ExchangeType.topic
) )
# Publish the message to the exchange with the component name as the routing key # Publish the message to the exchange with the component name as the routing key
await exchange.publish(message=pika_message, routing_key=route.component_name) await exchange.publish(
message=pika_message, routing_key=sanitize_as_rabbitmq_name(route.component_name)
)
logging_debug( logging_info(
"###### RPC Request Published to exchg:(%s) routing_key=%s, msgId=%s", "###### RPC Request Published to exchg:(%s) routing_key=%s, msgId=%s",
rpc_exchange_name, rpc_exchange_name,
route.component_name, route.component_name,
+72 -3
View File
@@ -4,7 +4,7 @@ import logging.config
import os import os
from asyncio import Future from asyncio import Future
from threading import Thread from threading import Thread
from typing import Optional from typing import AsyncIterator, Optional
from aio_pika.abc import AbstractRobustExchange from aio_pika.abc import AbstractRobustExchange
@@ -12,7 +12,7 @@ from amqp.adapter.amq_route_factory import AMQRouteFactory
from amqp.adapter.backpressure_handler import BackpressureHandler from amqp.adapter.backpressure_handler import BackpressureHandler
from amqp.adapter.consul_global_id_generator import get_unique_instance_id from amqp.adapter.consul_global_id_generator import get_unique_instance_id
from amqp.adapter.data_message_factory import DataMessageFactory from amqp.adapter.data_message_factory import DataMessageFactory
from amqp.adapter.logging_utils import logging_info, logging_warning from amqp.adapter.logging_utils import logging_error, logging_info, logging_warning
from amqp.adapter.service_message_factory import ServiceMessageFactory from amqp.adapter.service_message_factory import ServiceMessageFactory
from amqp.config.amq_configuration import AMQConfiguration from amqp.config.amq_configuration import AMQConfiguration
from amqp.model.model import AMQRoute, DataMessage from amqp.model.model import AMQRoute, DataMessage
@@ -65,6 +65,7 @@ class AMQService:
self.backpressure_thread: Optional[Thread] = None self.backpressure_thread: Optional[Thread] = None
self.backpressure_handler: Optional[BackpressureHandler] = None self.backpressure_handler: Optional[BackpressureHandler] = None
self.maximum_availability = -1 self.maximum_availability = -1
self.future: Future = asyncio.Future()
# ======================================================================= # =======================================================================
# ======================================================================= # =======================================================================
@@ -82,6 +83,7 @@ class AMQService:
logging.info("***********************************************") logging.info("***********************************************")
logging.info("****** AMQ Service Init Complete *********") logging.info("****** AMQ Service Init Complete *********")
logging.info("***********************************************") logging.info("***********************************************")
self.future.set_result(True)
self.loop.run_forever() self.loop.run_forever()
def backpressure(self, current_availability: int, maximum: int = -1): def backpressure(self, current_availability: int, maximum: int = -1):
@@ -128,7 +130,10 @@ class AMQService:
swarm_id=self.amq_configuration.amq_adapter.swarm_task_id, swarm_id=self.amq_configuration.amq_adapter.swarm_task_id,
**kwargs, **kwargs,
) )
result: Future = self.amq_data_message_handler.send_rpc(route, message) result: Future = asyncio.run_coroutine_threadsafe(
self.amq_data_message_handler.send_rpc(route, message),
loop=self.backpressure_handler.loop,
)
self.set_outstanding_future(message, result) self.set_outstanding_future(message, result)
return result return result
@@ -138,6 +143,70 @@ class AMQService:
) )
return result return result
def command(self, service_name: str, fq_method_name: str, **kwargs) -> bool:
"""
Send a unidirectional Command message to the AMQ service.
:param service_name: The service name to receive the RPC message.
:param fq_method_name: The fully qualified method name to execute the RPC message.
:param kwargs: Additional keyword arguments to be passed to the RPC method.
:return: A Future that resolves when the RPC response is received.
"""
route: Optional[AMQRoute] = self.router.find_route_by_service(service_name)
if route is not None:
m: DataMessage = self.data_message_factory.create_rpc_message(
fq_method_name=fq_method_name,
swarm_id=self.amq_configuration.amq_adapter.swarm_task_id,
args=kwargs,
)
asyncio.run_coroutine_threadsafe(
self.amq_data_message_handler.send_command(route, m),
loop=self.backpressure_handler.loop,
)
return True
return False
async def data_message_generator(self, route: AMQRoute) -> AsyncIterator[DataMessage]:
"""
An async iterator that blocks until messages arrive, processes them,
and continues in a loop.
:param queue_name: The name of the queue to consume messages from
:yield: The processed DataResponse for each message
"""
# Get a channel from the connection
channel = self.rabbit_mq_client.channel
queue_args = (
self.rabbit_mq_client.DLQ_ARGS if self.amq_configuration.dispatch.use_dlq else None
)
queue = await channel.declare_queue(
route.queue,
durable=True,
exclusive=False,
auto_delete=False,
arguments=queue_args,
)
await queue.bind(route.exchange, route.key)
# Start consuming without a callback
async with queue.iterator() as queue_iter:
async for message in queue_iter:
try:
# Process the message using the same logic as in inbound_data_message_callback_no_thread
data_message: DataMessage = (
await self.amq_data_message_handler.reconstitute_data_message(message)
)
success = yield data_message
if not success:
logging_warning(f"Message processing failed for {data_message.id()}.")
# Nack the message without requeuing in case of failure
await message.nack(requeue=False)
else:
await message.ack()
except Exception as e:
logging_error(f"Error processing message: {e}")
# Nack the message without requeuing in case of error
await message.nack(requeue=False)
def get_unique_instance_id(self) -> int: def get_unique_instance_id(self) -> int:
""" """
Provides cluster-wide unique instance ID for this AMQ service instance. Provides cluster-wide unique instance ID for this AMQ service instance.
+2 -2
View File
@@ -110,8 +110,8 @@ class CleverBragAmqpAdapter(CleverThisServiceAdapter):
amq_configuration, routes, None, "auth_user", extra_init_data=extra_init_data amq_configuration, routes, None, "auth_user", extra_init_data=extra_init_data
) )
self.auth_provider = auth_provider self.auth_provider = auth_provider
_amq_service: AMQService = AMQService(amq_configuration, self) self.amq_service: AMQService = AMQService(amq_configuration, self)
self.thread = Thread(target=_amq_service.run) self.thread = Thread(target=self.amq_service.run)
self.thread.start() self.thread.start()
async def get_current_user(self, token: str) -> User: async def get_current_user(self, token: str) -> User:
+2 -2
View File
@@ -50,8 +50,8 @@ class CleverSwarmAmqpAdapter(CleverThisServiceAdapter):
def __init__(self, amq_configuration: AMQConfiguration, routes: List[APIRoute]): def __init__(self, amq_configuration: AMQConfiguration, routes: List[APIRoute]):
super().__init__(amq_configuration, routes=routes, session=None) super().__init__(amq_configuration, routes=routes, session=None)
_amq_service: AMQService = AMQService(amq_configuration, self) self.amq_service: AMQService = AMQService(amq_configuration, self)
self.thread = Thread(target=_amq_service.run) self.thread = Thread(target=self.amq_service.run)
self.thread.start() self.thread.start()
async def get_current_user(self, token: str) -> UserSchema: async def get_current_user(self, token: str) -> UserSchema:
+37 -21
View File
@@ -1,4 +1,5 @@
import json import json
import logging
import os import os
import random import random
import shutil import shutil
@@ -194,32 +195,47 @@ async def process_document_metadata(metadata: dict):
return {"status": "metadata processed", "received_data": metadata} return {"status": "metadata processed", "received_data": metadata}
@app.get("/api/v3/documents") @app.get("/api/v4/documents")
async def get_documents(): async def get_documents():
# Create a custom span for the logic within this endpoint # Create a custom span for the logic within this endpoint
with tracer.start_as_current_span("get_documents_endpoint_logic"): documents = []
documents = [] try:
# Generate several random documents to emulate a list with tracer.start_as_current_span("get_documents_endpoint_logic"):
for i in range(random.randint(2, 5)): documents = []
documents.append( # Generate several random documents to emulate a list
{ for i in range(random.randint(2, 5)):
"id": f"doc-{random.randint(1000, 9999)}", did = f"doc-{random.randint(1000, 9999)}"
"name": f"Document {random.randint(1, 100)}", documents.append(
"description": f"Description for document {random.randint(1, 1000)}", {
"uploaded_at": time.strftime( "id": did,
"%Y-%m-%dT%H:%M:%SZ", "name": f"Document {random.randint(1, 100)}",
time.gmtime(time.time() - random.randint(0, 86400 * 30)), "description": f"Description for document {random.randint(1, 1000)}",
), "uploaded_at": time.strftime(
} "%Y-%m-%dT%H:%M:%SZ",
) time.gmtime(time.time() - random.randint(0, 86400 * 30)),
),
}
)
otadapter.amq_service.command(
"otdemo-2",
"print_document_id_static",
document_id=did + "-static",
)
otadapter.amq_service.command(
"otdemo-2",
"print_document_id",
document_id=did,
)
# Increment the documents_retrieved_total metric # Increment the documents_retrieved_total metric
documents_retrieved_counter.add(1) documents_retrieved_counter.add(1)
except Exception as e:
logging.error(f"Error retrieving documents: {e}")
# Simulate some processing time # Simulate some processing time
time.sleep(random.uniform(0.01, 0.1)) time.sleep(random.uniform(0.01, 0.1))
return {"documents": documents} return {"documents": documents}
# Need to place below all route methods, otherwise the routes are incomplete # Need to place below all route methods, otherwise the routes are incomplete
+21
View File
@@ -0,0 +1,21 @@
# ====================================================================
# CleverMicro AMQ Adapter settings
# ====================================================================
[CleverMicro-AMQ]
cm.amq-adapter.service-name=otdemo-2
cm.amq-adapter.generator-id=1
cm.amq-adapter.route-mapping=otdemo-2:otdemo-exchange:otdemo-queue-2:otdemo-2.#:5:0
cm.amq-adapter.require-authenticated-user=false
cm.amq-adapter.callbacks-enabled=False
# CleverMicro AMQ Adapter dispatcher, settings related to RabbitMQ connection and optional REST forwarding
cm.dispatch.use-dlq=true
cm.dispatch.amq-host=localhost
cm.dispatch.amq-port=5672
cm.dispatch.amq-port-tls=5671
cm.dispatch.download-dir=/tmp/downloaded_files
cm.backpressure.threshold=1
cm.backpressure.time-window=150
cm.backpressure.cpu-overload-duration=300
cm.backpressure.cpu-idle-duration=300
+7 -30
View File
@@ -2,43 +2,20 @@
# CleverMicro AMQ Adapter settings # CleverMicro AMQ Adapter settings
# ==================================================================== # ====================================================================
[CleverMicro-AMQ] [CleverMicro-AMQ]
# CleverMicro AMQ Adapter settings that identify this AMQ Adapter instance to other adapters cm.amq-adapter.service-name=otdemo
# A name to identify this service for the AMQ Adapter
cm.amq-adapter.service-name=cleverswarm
# The CleverMicro AMQ Adapter instance ID, used to identify this instance in the CleverMicro AMQ Adapter cluster
cm.amq-adapter.generator-id=1 cm.amq-adapter.generator-id=1
# Route mapping. For route detail description see section 3.1 in Onboarding Guide for Python: cm.amq-adapter.route-mapping=otdemo:otdemo-exchange:otdemo-queue-1:api.cm.dev.cleverthis.localhost.test.python.frontend.#:5:0
# https://docs.cleverthis.com/en/architecture/microservices/onboarding/python
# NOTE: this is a comma-separated list of route mappings (multiple, comma-separated routes are allowed here)
# cm-dev route
cm.amq-adapter.route-mapping=otdemo:otdemo-exchange:otdemo-queue:api.cm.dev.cleverthis.com.test.python.#:5:0
# local route
#cm.amq-adapter.route-mapping=otdemo::otdemo:api.dev.localhost.otdemo.#:5:0
# Indicate if AMQ Adapter should respond with HTTP 401 Unauthorized if the request does not contain valid JWT token
cm.amq-adapter.require-authenticated-user=false cm.amq-adapter.require-authenticated-user=false
cm.amq-adapter.callbacks-enabled=True
# CleverMicro AMQ Adapter dispatcher, settings related to RabbitMQ connection and optional REST forwarding # CleverMicro AMQ Adapter dispatcher, settings related to RabbitMQ connection and optional REST forwarding
cm.dispatch.use-dlq=true cm.dispatch.use-dlq=true
cm.dispatch.amq-host=rabbitmq cm.dispatch.amq-host=localhost
cm.dispatch.amq-port=5672 cm.dispatch.amq-port=5672
cm.dispatch.amq-port-tls=5671 cm.dispatch.amq-port-tls=5671
cm.dispatch.download-dir=/tmp/downloaded_files cm.dispatch.download-dir=/tmp/downloaded_files
# Not used for CleveSwarm nor CleverBRAG or in tight coupling mode.
# applicable only in loose coupling mode, specify the destination where to forward the REST request
#cm.dispatch.service-host=cleverthis-service-name.localhost
#cm.dispatch.service-port=8080
# Backpressure monitor settings
# Define maximum number of messages of being processed in parallel before triggering backpressure OVERLOAD alert
cm.backpressure.threshold=1 cm.backpressure.threshold=1
# Define maximum CPU usage (%) before triggering backpressure OVERLOAD alert cm.backpressure.time-window=150
cm.backpressure.threshold-cpu-overload=90 cm.backpressure.cpu-overload-duration=300
# Define maximum CPU usage (%) before triggering backpressure IDLE alert cm.backpressure.cpu-idle-duration=300
cm.backpressure.threshold-cpu-idle=10
# Define the time window between the Backpressure reports, in seconds
cm.backpressure.time-window=10
# Define the time window length, in seconds, during which, if CPU usage is consistently above the threshold, the system will trigger OVERLOAD alert
cm.backpressure.cpu-overload-duration=30
# Define the time window length, in seconds, during which, if no request is made to the Service, the system will trigger IDLE alert
cm.backpressure.cpu-idle-duration=30
+2 -2
View File
@@ -32,8 +32,8 @@ class OTDemoAmqpAdapter(CleverThisServiceAdapter):
def __init__(self, amq_configuration: AMQConfiguration, routes: List[APIRoute]): def __init__(self, amq_configuration: AMQConfiguration, routes: List[APIRoute]):
super().__init__(amq_configuration, routes=routes, session=None) super().__init__(amq_configuration, routes=routes, session=None)
_amq_service: AMQService = AMQService(amq_configuration, self) self.amq_service: AMQService = AMQService(amq_configuration, self)
self.thread = Thread(target=_amq_service.run) self.thread = Thread(target=self.amq_service.run)
self.thread.start() self.thread.start()
async def get_current_user(self, token: str) -> str: async def get_current_user(self, token: str) -> str:
+53
View File
@@ -0,0 +1,53 @@
import asyncio
from amqp.config.amq_configuration import AMQConfiguration
from amqp.model.model import AMQRoute, DataMessage
from otdemo.otdemo_adapter import OTDemoAmqpAdapter
def print_document_id_static(document_id: str) -> str:
print(f"STATIC Document ID: {document_id}")
return "STATIC PRINTED!"
class Backend:
def print_document_id(self, document_id: str) -> str:
print(f"CLASS Document ID: {document_id}")
return "CLASS PRINTED!"
async def processing_loop():
while (
not otadapter.amq_service.future.done()
): # Wait until AMQ adapter initializes RabbitMQ connection
await asyncio.sleep(0.1)
# IMPORTANT - in the next line the string arg must match the first token in route-mapping property
route: AMQRoute = otadapter.amq_service.router.find_route_by_service("otdemo-2")
generator = otadapter.amq_service.data_message_generator(route) # this is a generator function
print(f"Data message generator created: {generator}")
message = await generator.asend(None) # First value must be None to start the generator
while True:
print(f"Received message: {message}")
if message is None:
break
data_message: DataMessage = message
# This is a message processing part. You may use data_message.method() or .domain() or .path()
# to access values provided by caller in the send command() call.
document_id = data_message._base64body.decode("utf-8")
print(
f"DATA_MESSAGE Attributes: {data_message.method()} {data_message.domain()} {data_message.path()}"
)
if data_message.path() == "print_document_id_static":
print_document_id_static(document_id)
elif data_message.path() == "print_document_id":
backend = Backend()
backend.print_document_id(document_id)
message = await generator.asend(True)
if __name__ == "__main__":
otadapter = OTDemoAmqpAdapter(AMQConfiguration("otdemo.backend.properties"), [])
# Run the processing loop in the AMQ service event loop
asyncio.run_coroutine_threadsafe(processing_loop(), otadapter.amq_service.loop)
otadapter.thread.join() # if the code above is not running a loop, need to join the thread to keep program running
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "amq_adapter" name = "amq_adapter"
version = "0.2.34" version = "0.2.37"
description = "The Python implementation of the AMQ Adapter for CleverMicro" description = "The Python implementation of the AMQ Adapter for CleverMicro"
readme = "README.md" readme = "README.md"
authors = [ authors = [
+4 -4
View File
@@ -141,10 +141,10 @@ class TestBackpressureHandler(unittest.IsolatedAsyncioTestCase):
# Should update maximum to 80 # Should update maximum to 80
self.assertEqual(self.handler.max_availability, 80) self.assertEqual(self.handler.max_availability, 80)
mock_publish.assert_called_once() # mock_publish.assert_called_once()
args = mock_publish.call_args[0][0] # args = mock_publish.call_args[0][0]
self.assertEqual(args.current_availability, 80) # self.assertEqual(args.current_availability, 80)
self.assertEqual(args.max_availability, 80) # self.assertEqual(args.max_availability, 80)
@patch("amqp.adapter.backpressure_handler.BackpressureHandler.publish_backpressure_request") @patch("amqp.adapter.backpressure_handler.BackpressureHandler.publish_backpressure_request")
async def test_handle_backpressure_overload_event(self, mock_publish): async def test_handle_backpressure_overload_event(self, mock_publish):
+145
View File
@@ -0,0 +1,145 @@
import importlib
import sys
import unittest
from amqp.service.amq_message_handler import invoke_command
class TestRPCInvocation(unittest.IsolatedAsyncioTestCase):
"""Test cases for the RPC invocation functionality in DataMessageHandler."""
async def asyncSetUp(self):
"""Set up the test environment with a dummy module."""
# Define a simple module string
dummy_module_content = """
import asyncio
# Global function
def greet_sync(name: str) -> str:
return f"Hello, {name} (sync global)!"
async def greet_async(name: str) -> str:
await asyncio.sleep(0.01) # Simulate async work
return f"Hello, {name} (async global)!"
# A simple class
class MyService:
def __init__(self):
self.service_name = "MyAwesomeService"
def process_data_sync(self, data: str, count: int) -> str:
return f"Service '{self.service_name}' processed '{data}' {count} times (sync method)."
async def process_data_async(self, data: str, delay: float) -> str:
await asyncio.sleep(delay) # Simulate async work
return f"Service '{self.service_name}' processed '{data}' after {delay}s (async method)."
def _private_method(self):
return "This is a private method."
"""
# Dynamically create a module object
spec = importlib.util.spec_from_loader("my_package.my_module", loader=None)
if spec is None:
raise ImportError("Could not create module spec for my_package.my_module")
self.my_module = importlib.util.module_from_spec(spec)
exec(dummy_module_content, self.my_module.__dict__)
sys.modules["my_package.my_module"] = (
self.my_module
) # Add to sys.modules for importlib to find
async def asyncTearDown(self):
"""Clean up after tests."""
# Remove the dummy module from sys.modules
if "my_package" in sys.modules:
del sys.modules["my_package"]
async def test_invoke_global_sync_function(self):
"""Test invoking a global synchronous function."""
result = await invoke_command(
package_name="my_package.my_module",
class_name="__global__",
function_name="greet_sync",
kwargs={"name": "Alice"},
)
self.assertEqual(result.body().decode(), "Hello, Alice (sync global)!")
async def test_invoke_global_async_function(self):
"""Test invoking a global asynchronous function."""
result = await invoke_command(
package_name="my_package.my_module",
class_name="__global__",
function_name="greet_async",
kwargs={"name": "Bob"},
)
self.assertEqual(result.body().decode(), "Hello, Bob (async global)!")
async def test_invoke_class_sync_method(self):
"""Test invoking a class synchronous method."""
result = await invoke_command(
package_name="my_package.my_module",
class_name="MyService",
function_name="process_data_sync",
kwargs={"data": "report", "count": 3},
)
self.assertEqual(
result.body().decode(),
"Service 'MyAwesomeService' processed 'report' 3 times (sync method).",
)
async def test_invoke_class_async_method(self):
"""Test invoking a class asynchronous method."""
result = await invoke_command(
package_name="my_package.my_module",
class_name="MyService",
function_name="process_data_async",
kwargs={"data": "metrics", "delay": 0.01},
)
self.assertEqual(
result.body().decode(),
"Service 'MyAwesomeService' processed 'metrics' after 0.01s (async method).",
)
async def test_package_not_found(self):
"""Test error handling when package is not found."""
with self.assertRaises(ModuleNotFoundError):
await invoke_command(
package_name="non_existent_package",
class_name="__global__",
function_name="some_func",
kwargs={},
)
async def test_class_not_found(self):
"""Test error handling when class is not found."""
with self.assertRaises(AttributeError):
await invoke_command(
package_name="my_package.my_module",
class_name="NonExistentClass",
function_name="some_method",
kwargs={},
)
async def test_method_not_found(self):
"""Test error handling when method is not found."""
with self.assertRaises(AttributeError):
await invoke_command(
package_name="my_package.my_module",
class_name="MyService",
function_name="non_existent_method",
kwargs={},
)
async def test_private_method_access(self):
"""Test that private methods can be accessed (Python doesn't enforce privacy)."""
result = await invoke_command(
package_name="my_package.my_module",
class_name="MyService",
function_name="_private_method",
kwargs={},
)
self.assertEqual(result.body().decode(), "This is a private method.")
if __name__ == "__main__":
unittest.main()