|
|
import os |
|
|
import yaml |
|
|
import json |
|
|
import copy |
|
|
import logging |
|
|
from typing import Callable, Any, Dict, List |
|
|
from pydantic import BaseModel, ValidationError |
|
|
from pydantic._internal._model_construction import ModelMetaclass |
|
|
|
|
|
from .logging import logger |
|
|
from .callbacks import callback_manager, exception_buffer |
|
|
from .module_utils import ( |
|
|
save_json, |
|
|
custom_serializer, |
|
|
parse_json_from_text, |
|
|
get_error_message, |
|
|
get_base_module_init_error_message |
|
|
) |
|
|
from .registry import register_module, MODULE_REGISTRY |
|
|
|
|
|
|
|
|
class MetaModule(ModelMetaclass): |
|
|
""" |
|
|
MetaModule is a metaclass that automatically registers all subclasses of BaseModule. |
|
|
|
|
|
|
|
|
Attributes: |
|
|
No public attributes |
|
|
""" |
|
|
def __new__(mcs, name, bases, namespace, **kwargs): |
|
|
""" |
|
|
Creates a new class and registers it in MODULE_REGISTRY. |
|
|
|
|
|
Args: |
|
|
mcs: The metaclass itself |
|
|
name: The name of the class being created |
|
|
bases: Tuple of base classes |
|
|
namespace: Dictionary containing the class attributes and methods |
|
|
**kwargs: Additional keyword arguments |
|
|
|
|
|
Returns: |
|
|
The created class object |
|
|
""" |
|
|
cls = super().__new__(mcs, name, bases, namespace) |
|
|
register_module(name, cls) |
|
|
return cls |
|
|
|
|
|
|
|
|
class BaseModule(BaseModel, metaclass=MetaModule): |
|
|
""" |
|
|
Base module class that serves as the foundation for all modules in the EvoAgentX framework. |
|
|
|
|
|
This class provides serialization/deserialization capabilities, supports creating instances from |
|
|
dictionaries, JSON, or files, and exporting instances to these formats. |
|
|
|
|
|
Attributes: |
|
|
class_name: The class name, defaults to None but is automatically set during subclass initialization |
|
|
model_config: Pydantic model configuration that controls type matching and behavior |
|
|
""" |
|
|
|
|
|
class_name: str = None |
|
|
|
|
|
model_config = {"arbitrary_types_allowed": True, "extra": "allow", "protected_namespaces": (), "validate_assignment": False} |
|
|
|
|
|
def __init_subclass__(cls, **kwargs): |
|
|
""" |
|
|
Subclass initialization method that automatically sets the class_name attribute. |
|
|
|
|
|
Args: |
|
|
cls (Type): The subclass being initialized |
|
|
**kwargs (Any): Additional keyword arguments |
|
|
""" |
|
|
super().__init_subclass__(**kwargs) |
|
|
cls.class_name = cls.__name__ |
|
|
|
|
|
def __init__(self, **kwargs): |
|
|
""" |
|
|
Initializes a BaseModule instance. |
|
|
|
|
|
Args: |
|
|
**kwargs (Any): Keyword arguments used to initialize the instance |
|
|
|
|
|
Raises: |
|
|
ValidationError: When parameter validation fails |
|
|
Exception: When other errors occur during initialization |
|
|
""" |
|
|
|
|
|
try: |
|
|
for field_name, _ in type(self).model_fields.items(): |
|
|
field_value = kwargs.get(field_name, None) |
|
|
if field_value: |
|
|
kwargs[field_name] = self._process_data(field_value) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
super().__init__(**kwargs) |
|
|
self.init_module() |
|
|
except (ValidationError, Exception) as e: |
|
|
exception_handler = callback_manager.get_callback("exception_buffer") |
|
|
if exception_handler is None: |
|
|
error_message = get_base_module_init_error_message( |
|
|
cls=self.__class__, |
|
|
data=kwargs, |
|
|
errors=e |
|
|
) |
|
|
logger.error(error_message) |
|
|
raise |
|
|
else: |
|
|
exception_handler.add(e) |
|
|
|
|
|
def init_module(self): |
|
|
""" |
|
|
Module initialization method that subclasses can override to provide additional initialization logic. |
|
|
""" |
|
|
pass |
|
|
|
|
|
def __str__(self) -> str: |
|
|
""" |
|
|
Returns a string representation of the object. |
|
|
|
|
|
Returns: |
|
|
str: String representation of the object |
|
|
""" |
|
|
return self.to_str() |
|
|
|
|
|
@property |
|
|
def kwargs(self) -> dict: |
|
|
""" |
|
|
Returns the extra fields of the model. |
|
|
|
|
|
Returns: |
|
|
dict: Dictionary containing all extra keyword arguments |
|
|
""" |
|
|
return self.model_extra |
|
|
|
|
|
@classmethod |
|
|
def _create_instance(cls, data: Dict[str, Any]) -> "BaseModule": |
|
|
""" |
|
|
Internal method for creating an instance from a dictionary. |
|
|
|
|
|
Args: |
|
|
data: Dictionary containing instance data |
|
|
|
|
|
Returns: |
|
|
BaseModule: The created instance |
|
|
""" |
|
|
processed_data = {k: cls._process_data(v) for k, v in data.items()} |
|
|
|
|
|
return cls.model_validate(processed_data) |
|
|
|
|
|
@classmethod |
|
|
def _process_data(cls, data: Any) -> Any: |
|
|
""" |
|
|
Recursive method for processing data, with special handling for dictionaries containing class_name. |
|
|
|
|
|
Args: |
|
|
data: Data to be processed |
|
|
|
|
|
Returns: |
|
|
Processed data |
|
|
""" |
|
|
if isinstance(data, dict): |
|
|
if "class_name" in data: |
|
|
sub_class = MODULE_REGISTRY.get_module(data.get("class_name")) |
|
|
return sub_class._create_instance(data) |
|
|
else: |
|
|
return {k: cls._process_data(v) for k, v in data.items()} |
|
|
elif isinstance(data, (list, tuple)): |
|
|
return [cls._process_data(x) for x in data] |
|
|
else: |
|
|
return data |
|
|
|
|
|
@classmethod |
|
|
def from_dict(cls, data: Dict[str, Any], **kwargs) -> "BaseModule": |
|
|
""" |
|
|
Instantiate the BaseModule from a dictionary. |
|
|
|
|
|
Args: |
|
|
data: Dictionary containing instance data |
|
|
**kwargs (Any): Additional keyword arguments, can include log to control logging output |
|
|
|
|
|
Returns: |
|
|
BaseModule: The created module instance |
|
|
|
|
|
Raises: |
|
|
Exception: When errors occur during initialization |
|
|
""" |
|
|
use_logger = kwargs.get("log", True) |
|
|
with exception_buffer() as buffer: |
|
|
try: |
|
|
class_name = data.get("class_name", None) |
|
|
if class_name: |
|
|
cls = MODULE_REGISTRY.get_module(class_name) |
|
|
module = cls._create_instance(data) |
|
|
|
|
|
if len(buffer.exceptions) > 0: |
|
|
error_message = get_base_module_init_error_message(cls, data, buffer.exceptions) |
|
|
if use_logger: |
|
|
logger.error(error_message) |
|
|
raise Exception(get_error_message(buffer.exceptions)) |
|
|
finally: |
|
|
pass |
|
|
return module |
|
|
|
|
|
@classmethod |
|
|
def from_json(cls, content: str, **kwargs) -> "BaseModule": |
|
|
""" |
|
|
Construct the BaseModule from a JSON string. |
|
|
|
|
|
This method uses yaml.safe_load to parse the JSON string into a Python object, |
|
|
which supports more flexible parsing than standard json.loads (including handling |
|
|
single quotes, trailing commas, etc). The parsed data is then passed to from_dict |
|
|
to create the instance. |
|
|
|
|
|
Args: |
|
|
content: JSON string |
|
|
**kwargs (Any): Additional keyword arguments, can include `log` to control logging output |
|
|
|
|
|
Returns: |
|
|
BaseModule: The created module instance |
|
|
|
|
|
Raises: |
|
|
ValueError: When the input is not a valid JSON string |
|
|
""" |
|
|
use_logger = kwargs.get("log", True) |
|
|
try: |
|
|
data = yaml.safe_load(content) |
|
|
except Exception: |
|
|
error_message = f"Can not instantiate {cls.__name__}. The input to {cls.__name__}.from_json is not a valid JSON string." |
|
|
if use_logger: |
|
|
logger.error(error_message) |
|
|
raise ValueError(error_message) |
|
|
|
|
|
if not isinstance(data, (list, dict)): |
|
|
error_message = f"Can not instantiate {cls.__name__}. The input to {cls.__name__}.from_json is not a valid JSON string." |
|
|
if use_logger: |
|
|
logger.error(error_message) |
|
|
raise ValueError(error_message) |
|
|
|
|
|
return cls.from_dict(data, log=use_logger) |
|
|
|
|
|
@classmethod |
|
|
def from_str(cls, content: str, **kwargs) -> "BaseModule": |
|
|
""" |
|
|
Construct the BaseModule from a string that may contain JSON. |
|
|
|
|
|
This method is more forgiving than `from_json` as it can extract valid JSON |
|
|
objects embedded within larger text. It uses `parse_json_from_text` to extract |
|
|
all potential JSON strings from the input text, then tries to create an instance |
|
|
from each extracted JSON string until successful. |
|
|
|
|
|
Args: |
|
|
content: Text that may contain JSON strings |
|
|
**kwargs (Any): Additional keyword arguments, can include `log` to control logging output |
|
|
|
|
|
Returns: |
|
|
BaseModule: The created module instance |
|
|
|
|
|
Raises: |
|
|
ValueError: When the input does not contain valid JSON strings or the JSON is incompatible with the class |
|
|
""" |
|
|
use_logger = kwargs.get("log", True) |
|
|
|
|
|
extracted_json_list = parse_json_from_text(content) |
|
|
if len(extracted_json_list) == 0: |
|
|
error_message = f"The input to {cls.__name__}.from_str does not contain any valid JSON str." |
|
|
if use_logger: |
|
|
logger.error(error_message) |
|
|
raise ValueError(error_message) |
|
|
|
|
|
module = None |
|
|
for json_str in extracted_json_list: |
|
|
try: |
|
|
module = cls.from_json(json_str, log=False) |
|
|
except Exception: |
|
|
continue |
|
|
break |
|
|
|
|
|
if module is None: |
|
|
error_message = f"Can not instantiate {cls.__name__}. The input to {cls.__name__}.from_str either does not contain a valide JSON str, or the JSON str is incomplete or incompatable (incorrect variables or types) with {cls.__name__}." |
|
|
error_message += f"\nInput:\n{content}" |
|
|
if use_logger: |
|
|
logger.error(error_message) |
|
|
raise ValueError(error_message) |
|
|
|
|
|
return module |
|
|
|
|
|
@classmethod |
|
|
def load_module(cls, path: str, **kwargs) -> dict: |
|
|
""" |
|
|
Load the values for a module from a file. |
|
|
|
|
|
By default, it opens the specified file and uses `yaml.safe_load` to parse its contents |
|
|
into a Python object (typically a dictionary). |
|
|
|
|
|
Args: |
|
|
path: The path of the file |
|
|
**kwargs (Any): Additional keyword arguments |
|
|
|
|
|
Returns: |
|
|
dict: The JSON object instantiated from the file |
|
|
""" |
|
|
with open(path, mode="r", encoding="utf-8") as file: |
|
|
content = yaml.safe_load(file.read()) |
|
|
return content |
|
|
|
|
|
@classmethod |
|
|
def from_file(cls, path: str, load_function: Callable=None, **kwargs) -> "BaseModule": |
|
|
""" |
|
|
Construct the BaseModule from a file. |
|
|
|
|
|
This method reads and parses a file into a data structure, then creates |
|
|
a module instance from that data. It first verifies that the file exists, |
|
|
then uses either the provided `load_function` or the default `load_module` |
|
|
method to read and parse the file content, and finally calls `from_dict` |
|
|
to create the instance. |
|
|
|
|
|
Args: |
|
|
path: The path of the file |
|
|
load_function: The function used to load the data, takes a file path as input and returns a JSON object |
|
|
**kwargs (Any): Additional keyword arguments, can include `log` to control logging output |
|
|
|
|
|
Returns: |
|
|
BaseModule: The created module instance |
|
|
|
|
|
Raises: |
|
|
ValueError: When the file does not exist |
|
|
""" |
|
|
use_logger = kwargs.get("log", True) |
|
|
if not os.path.exists(path): |
|
|
error_message = f"File \"{path}\" does not exist!" |
|
|
if use_logger: |
|
|
logger.error(error_message) |
|
|
raise ValueError(error_message) |
|
|
|
|
|
function = load_function or cls.load_module |
|
|
content = function(path, **kwargs) |
|
|
module = cls.from_dict(content, log=use_logger) |
|
|
|
|
|
return module |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self, exclude_none: bool = True, ignore: List[str] = [], **kwargs) -> dict: |
|
|
""" |
|
|
Convert the BaseModule to a dictionary. |
|
|
|
|
|
Args: |
|
|
exclude_none: Whether to exclude fields with None values |
|
|
ignore: List of field names to ignore |
|
|
**kwargs (Any): Additional keyword arguments |
|
|
|
|
|
Returns: |
|
|
dict: Dictionary containing the object data |
|
|
""" |
|
|
data = {} |
|
|
for field_name, _ in type(self).model_fields.items(): |
|
|
if field_name in ignore: |
|
|
continue |
|
|
field_value = getattr(self, field_name, None) |
|
|
if exclude_none and field_value is None: |
|
|
continue |
|
|
if isinstance(field_value, BaseModule): |
|
|
data[field_name] = field_value.to_dict(exclude_none=exclude_none, ignore=ignore) |
|
|
elif isinstance(field_value, list): |
|
|
data[field_name] = [ |
|
|
item.to_dict(exclude_none=exclude_none, ignore=ignore) if isinstance(item, BaseModule) else item |
|
|
for item in field_value |
|
|
] |
|
|
elif isinstance(field_value, dict): |
|
|
data[field_name] = { |
|
|
key: value.to_dict(exclude_none=exclude_none, ignore=ignore) if isinstance(value, BaseModule) else value |
|
|
for key, value in field_value.items() |
|
|
} |
|
|
else: |
|
|
data[field_name] = field_value |
|
|
|
|
|
return data |
|
|
|
|
|
def to_json(self, use_indent: bool=False, ignore: List[str] = [], **kwargs) -> str: |
|
|
""" |
|
|
Convert the BaseModule to a JSON string. |
|
|
|
|
|
Args: |
|
|
use_indent: Whether to use indentation |
|
|
ignore: List of field names to ignore |
|
|
**kwargs (Any): Additional keyword arguments |
|
|
|
|
|
Returns: |
|
|
str: The JSON string |
|
|
""" |
|
|
if use_indent: |
|
|
kwargs["indent"] = kwargs.get("indent", 4) |
|
|
else: |
|
|
kwargs.pop("indent", None) |
|
|
if kwargs.get("default", None) is None: |
|
|
kwargs["default"] = custom_serializer |
|
|
data = self.to_dict(exclude_none=True) |
|
|
for ignore_field in ignore: |
|
|
data.pop(ignore_field, None) |
|
|
return json.dumps(data, **kwargs) |
|
|
|
|
|
def to_str(self, **kwargs) -> str: |
|
|
""" |
|
|
Convert the BaseModule to a string. Use .to_json to output JSON string by default. |
|
|
|
|
|
Args: |
|
|
**kwargs (Any): Additional keyword arguments |
|
|
|
|
|
Returns: |
|
|
str: The string |
|
|
""" |
|
|
return self.to_json(use_indent=False) |
|
|
|
|
|
def save_module(self, path: str, ignore: List[str] = [], **kwargs)-> str: |
|
|
""" |
|
|
Save the BaseModule to a file. |
|
|
|
|
|
This method will set non-serializable objects to None by default. |
|
|
If you want to save non-serializable objects, override this method. |
|
|
Remember to also override the `load_module` function to ensure the loaded |
|
|
object can be correctly parsed by `cls.from_dict`. |
|
|
|
|
|
Args: |
|
|
path: The path to save the file |
|
|
ignore: List of field names to ignore |
|
|
**kwargs (Any): Additional keyword arguments |
|
|
|
|
|
Returns: |
|
|
str: The path where the file is saved, same as the input path |
|
|
""" |
|
|
logger.info("Saving {} to {}", self.__class__.__name__, path) |
|
|
return save_json(self.to_json(use_indent=True, default=lambda x: None, ignore=ignore), path=path) |
|
|
|
|
|
def deepcopy(self): |
|
|
"""Deep copy the module. |
|
|
|
|
|
This is a tweak to the default python deepcopy that only deep copies `self.parameters()`, and for other |
|
|
attributes, we just do the shallow copy. |
|
|
""" |
|
|
try: |
|
|
|
|
|
|
|
|
return copy.deepcopy(self) |
|
|
except Exception: |
|
|
pass |
|
|
|
|
|
|
|
|
new_instance = self.__class__.__new__(self.__class__) |
|
|
|
|
|
for attr, value in self.__dict__.items(): |
|
|
if isinstance(value, BaseModule): |
|
|
setattr(new_instance, attr, value.deepcopy()) |
|
|
else: |
|
|
try: |
|
|
|
|
|
setattr(new_instance, attr, copy.deepcopy(value)) |
|
|
except Exception: |
|
|
logging.warning( |
|
|
f"Failed to deep copy attribute '{attr}' of {self.__class__.__name__}, " |
|
|
"falling back to shallow copy or reference copy." |
|
|
) |
|
|
try: |
|
|
|
|
|
setattr(new_instance, attr, copy.copy(value)) |
|
|
except Exception: |
|
|
|
|
|
setattr(new_instance, attr, value) |
|
|
|
|
|
return new_instance |
|
|
__all__ = ["BaseModule"] |
|
|
|
|
|
|