From 10bbd62aa27dd0d842368cb03b63c56779a6fc97 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 28 Apr 2025 14:48:20 +0200 Subject: [PATCH] Convert SerenaConfig and ProjectConfig to dataclasses, facilitating testing --- scripts/demo_run_tools.py | 2 +- src/serena/agent.py | 118 ++++++++++++++++++++++++++++---------- 2 files changed, 89 insertions(+), 31 deletions(-) diff --git a/scripts/demo_run_tools.py b/scripts/demo_run_tools.py index 0ebcc0c3..4d639b48 100644 --- a/scripts/demo_run_tools.py +++ b/scripts/demo_run_tools.py @@ -13,7 +13,7 @@ from serena.agent import * project_root = Path(__file__).parent.parent if __name__ == "__main__": - agent = SerenaAgent(project_file_path=str(project_root / ".serena" / "project.yml")) + agent = SerenaAgent(project_config=str(project_root / ".serena" / "project.yml")) overview_tool = agent.get_tool(GetSymbolsOverviewTool) find_symbol_tool = agent.get_tool(FindSymbolTool) diff --git a/src/serena/agent.py b/src/serena/agent.py index fe8028af..554eb5b8 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -11,6 +11,7 @@ import traceback from abc import ABC from collections import defaultdict from collections.abc import Callable, Generator, Iterable +from dataclasses import field from logging import Logger from pathlib import Path from typing import TYPE_CHECKING, Any, Self, TypeVar, Union, cast @@ -28,7 +29,6 @@ from serena import serena_root_path, serena_version from serena.llm.prompt_factory import PromptFactory from serena.symbol import SymbolLocation, SymbolManager from serena.text_utils import search_files -from serena.util.class_decorators import singleton from serena.util.file_system import scan_directory from serena.util.inspection import iter_subclasses from serena.util.shell import execute_shell_command @@ -67,22 +67,40 @@ class SerenaConfigError(Exception): pass +import logging +from dataclasses import dataclass + + +@dataclass class ProjectConfig(ToStringMixin): + project_name: str + language: Language + project_root: str + ignored_paths: list[str] = field(default_factory=list) + excluded_tools: set[str] = field(default_factory=set) + read_only: bool = False + ignore_all_files_in_gitignore: bool = True + SERENA_MANAGED_DIR = ".serena" SERENA_DEFAULT_PROJECT_FILE = "project.yml" - def __init__(self, config_dict: dict[str, Any], project_name: str, project_root: Path | None = None): - self.project_name: str = project_name + @classmethod + def from_config_dict(cls, config_dict: dict[str, Any], project_name: str, project_root: Path | None = None) -> Self: + """ + Create a ProjectConfig instance from a configuration dictionary + """ try: - self.language: Language = Language(config_dict["language"].lower()) + language = Language(config_dict["language"].lower()) except ValueError as e: raise ValueError(f"Invalid language: {config_dict['language']}.\nValid languages are: {[l.value for l in Language]}") from e + if project_root is None: project_root = Path(config_dict["project_root"]) - self.project_root: str = str(project_root.resolve()) - self.ignored_paths: list[str] = config_dict.get("ignored_paths", []) - self.excluded_tools: set[str] = set(config_dict.get("excluded_tools", [])) - self.read_only: bool = config_dict.get("read_only", False) + project_root_str = str(project_root.resolve()) + + ignored_paths = config_dict.get("ignored_paths", []) + excluded_tools = set(config_dict.get("excluded_tools", [])) + read_only = config_dict.get("read_only", False) if "ignore_all_files_in_gitignore" not in config_dict: raise SerenaConfigError( @@ -90,7 +108,7 @@ class ProjectConfig(ToStringMixin): "Please update your `.yml` configuration file for this project. " "It is recommended to set this to `True`." ) - self.ignore_all_files_in_gitignore = config_dict["ignore_all_files_in_gitignore"] + ignore_all_files_in_gitignore = config_dict["ignore_all_files_in_gitignore"] # Raise errors for deprecated keys if "ignored_dirs" in config_dict: @@ -99,9 +117,24 @@ class ProjectConfig(ToStringMixin): "Note that you can also set `ignore_all_files_in_gitignore` to `True`, which will be enough for most cases." ) + return cls( + project_name=project_name, + language=language, + project_root=project_root_str, + ignored_paths=ignored_paths, + excluded_tools=excluded_tools, + read_only=read_only, + ignore_all_files_in_gitignore=ignore_all_files_in_gitignore, + ) + @classmethod def from_yml(cls, yml_path: Path) -> Self: + """ + Create a ProjectConfig instance from a YAML file + """ log.info(f"Loading project configuration from {yml_path}") + if not os.path.exists(yml_path): + raise FileNotFoundError(f"Project file not found: {yml_path}") try: with open(yml_path, encoding="utf-8") as f: config_dict = yaml.safe_load(f) @@ -111,7 +144,7 @@ class ProjectConfig(ToStringMixin): else: project_root = None project_name = yml_path.stem - return cls(config_dict, project_name=project_name, project_root=project_root) + return cls.from_config_dict(config_dict, project_name=project_name, project_root=project_root) except Exception as e: raise ValueError(f"Error loading project configuration from {yml_path}: {e}") from e @@ -119,18 +152,28 @@ class ProjectConfig(ToStringMixin): return os.path.join(self.project_root, self.SERENA_MANAGED_DIR) -@singleton class SerenaConfig: """ Handles user-defined Serena configuration based on the configuration file """ + projects: dict[str, "ProjectConfig"] = field(default_factory=dict) + project_names: list[str] = field(default_factory=list) + gui_log_window_enabled: bool = False + gui_log_window_level: int = logging.INFO + enable_project_activation: bool = True + CONFIG_FILE = "serena_config.yml" - def __init__(self) -> None: - config_file = os.path.join(serena_root_path(), self.CONFIG_FILE) + @classmethod + def from_config_file(cls) -> "SerenaConfig": + """ + Static constructor to create SerenaConfig from the configuration file + """ + config_file = os.path.join(serena_root_path(), cls.CONFIG_FILE) if not os.path.exists(config_file): raise FileNotFoundError(f"Serena configuration file not found: {config_file}") + with open(config_file, encoding="utf-8") as f: try: log.info(f"Loading Serena configuration from {config_file}") @@ -138,10 +181,13 @@ class SerenaConfig: except Exception as e: raise ValueError(f"Error loading Serena configuration from {config_file}: {e}") from e + # Create instance + instance = cls() + # read projects - self.projects: dict[str, ProjectConfig] = {} if "projects" not in config_yaml: raise SerenaConfigError("`projects` key not found in Serena configuration. Please update your `serena_config.yml` file.") + projects = {} for project_config_path in config_yaml["projects"]: project_config_path = Path(project_config_path) if not project_config_path.is_absolute(): @@ -152,14 +198,17 @@ class SerenaConfig: raise FileNotFoundError(f"Project file not found: {project_config_path}") log.info(f"Loading project configuration from {project_config_path}") project_config = ProjectConfig.from_yml(project_config_path) - self.projects[project_config.project_name] = project_config - self.project_names = list(self.projects.keys()) + projects[project_config.project_name] = project_config - self.gui_log_window_enabled = config_yaml.get("gui_log_window", False) - self.gui_log_window_level = config_yaml.get("gui_log_level", logging.INFO) - self.enable_project_activation = config_yaml.get("enable_project_activation", True) + instance.projects = projects + instance.project_names = list(projects.keys()) + instance.gui_log_window_enabled = config_yaml.get("gui_log_window", False) + instance.gui_log_window_level = config_yaml.get("gui_log_level", logging.INFO) + instance.enable_project_activation = config_yaml.get("enable_project_activation", True) - def get_project_configuration(self, project_name: str) -> ProjectConfig: + return instance + + def get_project_configuration(self, project_name: str) -> "ProjectConfig": if project_name not in self.projects: raise ValueError(f"Project '{project_name}' not found in Serena configuration; valid project names: {self.project_names}") return self.projects[project_name] @@ -182,15 +231,22 @@ class LinesRead: class SerenaAgent: - def __init__(self, project_file_path: str | None = None, project_activation_callback: Callable[[], None] | None = None): + def __init__( + self, + project_config: ProjectConfig | str | None = None, + project_activation_callback: Callable[[], None] | None = None, + serena_config: SerenaConfig | None = None, + ) -> None: """ - :param project_file_path: the configuration file (.yml) of the project to load immediately; + :param project_config: the project to load immediately; may be a path to the project configuration file + or a configuration instance; if None, do not load any project (must use project selection tool to activate a project). If a project is provided, the corresponding language server will be started. :param project_activation_callback: a callback function to be called when a project is activated. + :param serena_config: the Serena configuration or None to read the configuration from the default location. """ # obtain serena configuration - self.serena_config = SerenaConfig() + self.serena_config = serena_config | SerenaConfig.from_config_file() # open GUI log window if enabled self._gui_log_handler: Union["GuiLogViewerHandler", None] = None # noqa @@ -240,12 +296,14 @@ class SerenaAgent: self._gui_log_handler.log_viewer.set_tool_names(tool_names) # activate a project configuration (if provided or if there is only a single project available) - project_config: ProjectConfig | None = None - if project_file_path is not None: - if not os.path.exists(project_file_path): - raise FileNotFoundError(f"Project file not found: {project_file_path}") - log.info(f"Loading project configuration from {project_file_path}") - project_config = ProjectConfig.from_yml(Path(project_file_path)) + project_config_to_load: ProjectConfig | None = None + if project_config is not None: + if isinstance(project_config, str): + project_config_to_load = ProjectConfig.from_yml(Path(project_config)) + elif isinstance(project_config, ProjectConfig): + project_config_to_load = project_config + else: + raise ValueError(f"Invalid project configuration: {project_config}, (type {type(project_config)})") else: match len(self.serena_config.projects): case 0: @@ -253,7 +311,7 @@ class SerenaAgent: case 1: project_config = self.serena_config.get_project_configuration(self.serena_config.project_names[0]) if project_config is not None: - self.activate_project(project_config) + self.activate_project(project_config_to_load) else: if not self.serena_config.enable_project_activation: raise ValueError("Tool-based project activation is disabled in the configuration but no project file was provided.")