Convert SerenaConfig and ProjectConfig to dataclasses,

facilitating testing
This commit is contained in:
Dominik Jain authored and Michael Panchenko committed 2025-04-28 16:39:32 +02:00
1 parent 50d1bccd0d
commit 10bbd62aa2
2 files changed
+89 -31

No files matched your search

+1 -1
View File
@@ -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)
+88 -30
View File
@@ -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.")