Merge pull request #1131 from cx-alberto-simoes/feature/configurable-serena-folder-location

feat: add configurable project_serena_folder_location setting

Add a new `project_serena_folder_location` setting to `serena_config.yml` that allows configuring where the per-project `.serena` data folder (memories, caches) is stored, with `$projectDir` and `$projectFolderName` placeholder support (default: `$projectDir/.serena`, preserving current behavior).

Resolves #1123

Co-authored-by: Dominik Jain <dominik.jain@oraios-ai.de>
This commit is contained in:
Alberto SimõesandDominik Jain authored and Dominik Jain committed 2026-03-05 21:09:13 +01:00
1 parent 4353fd34dd
commit 58efe413e3
13 files changed
+418 -76

No files matched your search

+29
View File
@@ -173,6 +173,35 @@ For advanced users, Serena's configuration can be further customized.
The Serena user data directory (where configuration, language server files, logs, etc. are stored) defaults to `~/.serena`.
You can change this location by setting the `SERENA_HOME` environment variable to your desired path.
### Per-Project Serena Folder Location
By default, each project stores its Serena data (memories, caches, etc.) in a `.serena` folder inside the project root.
You can customize this location globally via the `project_serena_folder_location` setting in `serena_config.yml`.
The setting supports two placeholders:
| Placeholder | Description |
|----------------------|-------------------------------------------------|
| `$projectDir` | The absolute path to the project root directory |
| `$projectFolderName` | The name of the project folder |
**Examples:**
```yaml
# Default: data stored inside the project directory
project_serena_folder_location: "$projectDir/.serena"
# Central location: all project data under a shared directory
project_serena_folder_location: "/projects-metadata/$projectFolderName/.serena"
```
When a project is loaded, Serena uses the following fallback logic:
1. Check if a `.serena` folder exists at the configured path.
2. If not, check if one exists in the project root (default/legacy location).
3. If neither exists, create the folder at the configured path.
This ensures backward compatibility: existing projects that already have a `.serena` folder in the project root will continue to work, even after changing the `project_serena_folder_location` setting.
(ls-specific-settings)=
### Language Server-Specific Settings
+7 -6
View File
@@ -599,7 +599,8 @@ class ProjectCommands(AutoRegisteringGroup):
:return: the RegisteredProject instance
"""
project_root = Path(project_path).resolve()
yml_path = ProjectConfig.path_to_project_yml(project_root)
serena_config = SerenaConfig.from_config_file()
yml_path = serena_config.get_project_yml_location(str(project_root))
if os.path.exists(yml_path):
raise FileExistsError(f"Project file {yml_path} already exists.")
@@ -613,14 +614,14 @@ class ProjectCommands(AutoRegisteringGroup):
raise ValueError(f"Unknown language '{lang}'. Supported: {all_langs}")
generated_conf = ProjectConfig.autogenerate(
project_root=project_path, project_name=name, languages=languages if languages else None, interactive=True
project_root=project_path,
serena_config=serena_config,
project_name=name,
languages=languages if languages else None,
interactive=True,
)
yml_path = ProjectConfig.path_to_project_yml(project_path)
languages_str = ", ".join([lang.value for lang in generated_conf.languages]) if generated_conf.languages else "N/A"
click.echo(f"Generated project with languages {{{languages_str}}} at {yml_path}.")
# add to SerenaConfig's list of registered projects
serena_config = SerenaConfig.from_config_file()
registered_project = serena_config.get_registered_project(str(project_root))
if registered_project is None:
registered_project = RegisteredProject(str(project_root), generated_conf)
+124 -28
View File
@@ -4,6 +4,7 @@ The Serena Model Context Protocol (MCP) Server
import dataclasses
import os
import re
import shutil
from collections.abc import Iterator, Sequence
from copy import deepcopy
@@ -195,8 +196,11 @@ class SerenaConfigError(Exception):
pass
def get_serena_managed_in_project_dir(project_root: str | Path) -> str:
return os.path.join(project_root, SERENA_MANAGED_DIR_NAME)
DEFAULT_PROJECT_SERENA_FOLDER_LOCATION = "$projectDir/" + SERENA_MANAGED_DIR_NAME
"""
The default template for the project Serena folder location.
Uses $projectDir and $projectFolderName as placeholders.
"""
@dataclass(kw_only=True)
@@ -224,6 +228,7 @@ class ProjectConfig(SharedConfig):
def autogenerate(
cls,
project_root: str | Path,
serena_config: "SerenaConfig",
project_name: str | None = None,
languages: list[Language] | None = None,
save_to_disk: bool = True,
@@ -233,6 +238,7 @@ class ProjectConfig(SharedConfig):
Autogenerate a project configuration for a given project root.
:param project_root: the path to the project root
:param serena_config: the global Serena configuration
:param project_name: the name of the project; if None, the name of the project will be the name of the directory
containing the project
:param languages: the languages of the project; if None, they will be determined automatically
@@ -245,7 +251,8 @@ class ProjectConfig(SharedConfig):
raise FileNotFoundError(f"Project root not found: {project_root}")
with LogTime("Project configuration auto-generation", logger=log):
log.info("Project root: %s", project_root)
project_name = project_name or project_root.name
project_folder_name = project_root.name
project_name = project_name or project_folder_name
if languages is None:
# determine languages automatically
log.info("Determining programming languages used in the project")
@@ -293,18 +300,19 @@ class ProjectConfig(SharedConfig):
config_with_comments["project_name"] = project_name
config_with_comments["languages"] = languages_to_use
if save_to_disk:
project_yml_path = cls.path_to_project_yml(project_root)
project_yml_path = serena_config.get_project_yml_location(str(project_root))
log.info("Saving project configuration to %s", project_yml_path)
save_yaml(project_yml_path, config_with_comments)
return cls._from_dict(config_with_comments)
@classmethod
def path_to_project_yml(cls, project_root: str | Path) -> str:
return os.path.join(project_root, cls.rel_path_to_project_yml())
@classmethod
def rel_path_to_project_yml(cls) -> str:
return os.path.join(SERENA_MANAGED_DIR_NAME, cls.SERENA_DEFAULT_PROJECT_FILE)
def default_project_yml_path(cls, project_root: str | Path) -> str:
"""
:return: the default path to the project.yml file (inside ``$projectDir/.serena/``).
This is suitable as a fallback when no ``SerenaConfig`` is available to resolve
a potentially customised location.
"""
return os.path.join(str(project_root), SERENA_MANAGED_DIR_NAME, cls.SERENA_DEFAULT_PROJECT_FILE)
@classmethod
def _load_yaml(
@@ -401,24 +409,29 @@ class ProjectConfig(SharedConfig):
return d
@classmethod
def load(cls, project_root: Path | str, autogenerate: bool = False) -> Self:
def load(cls, project_root: Path | str, serena_config: "SerenaConfig", autogenerate: bool = False) -> Self:
"""
Load a ProjectConfig instance from the path to the project root.
:param project_root: the path to the project root
:param serena_config: the global Serena configuration
:param autogenerate: whether to auto-generate the configuration if it does not exist
"""
project_root = Path(project_root)
yaml_path = project_root / cls.rel_path_to_project_yml()
project_folder_name = project_root.name
yaml_path = serena_config.get_project_yml_location(project_root)
# auto-generate if necessary
if not yaml_path.exists():
if not os.path.exists(yaml_path):
if autogenerate:
return cls.autogenerate(project_root)
return cls.autogenerate(project_root, serena_config)
else:
raise FileNotFoundError(f"Project configuration file not found: {yaml_path}")
# load the configuration dictionary
yaml_data, was_complete = cls._load_yaml(str(yaml_path))
if "project_name" not in yaml_data:
yaml_data["project_name"] = project_root.name
yaml_data["project_name"] = project_folder_name
# instantiate the ProjectConfig
project_config = cls._from_dict(yaml_data)
@@ -426,17 +439,17 @@ class ProjectConfig(SharedConfig):
# if the configuration was incomplete, re-save it to disk
if not was_complete:
log.info("Project configuration in %s was incomplete, re-saving with default values for missing fields", yaml_path)
project_config.save(project_root)
project_config.save(str(yaml_path))
return project_config
def save(self, project_root: Path | str) -> None:
def save(self, project_yml_path: str) -> None:
"""
Saves the project configuration to disk.
:param project_root: the root directory of the project
:param project_yml_path: the path to the project.yml file
"""
config_path = self.path_to_project_yml(project_root)
config_path = project_yml_path
log.info("Saving updated project configuration to %s", config_path)
# load original commented map and update it with current values
@@ -484,8 +497,8 @@ class RegisteredProject(ToStringMixin):
)
@classmethod
def from_project_root(cls, project_root: str | Path) -> "RegisteredProject":
project_config = ProjectConfig.load(project_root)
def from_project_root(cls, project_root: str | Path, serena_config: "SerenaConfig") -> "RegisteredProject":
project_config = ProjectConfig.load(project_root, serena_config=serena_config)
return RegisteredProject(
project_root=str(project_root),
project_config=project_config,
@@ -564,6 +577,17 @@ class SerenaConfig(SharedConfig):
edit_global_memories: bool = True
"""Whether global memories are allowed to be deleted or edited."""
project_serena_folder_location: str = DEFAULT_PROJECT_SERENA_FOLDER_LOCATION
"""
Template for the location of the per-project .serena data folder (memories, caches, etc.).
Supports the following placeholders:
- $projectDir: the absolute path to the project root directory
- $projectFolderName: the name of the project folder
Examples:
- "$projectDir/.serena" (default, stores data inside the project)
- "/projects-metadata/$projectFolderName/.serena" (stores data in a central location)
"""
# settings with overridden defaults
default_modes: Sequence[str] | None = ("interactive", "editing")
symbol_info_budget: float = 10.0
@@ -689,15 +713,15 @@ class SerenaConfig(SharedConfig):
instance.projects = []
for path in loaded_commented_yaml["projects"]:
path = Path(path).resolve()
if not path.exists() or (path.is_dir() and not (path / ProjectConfig.rel_path_to_project_yml()).exists()):
log.warning(f"Project path {path} does not exist or does not contain a project configuration file, skipping.")
if not path.exists() or (path.is_dir() and not os.path.isfile(instance.get_project_yml_location(str(path)))):
log.warning(f"Project path {path} does not exist or no associated project configuration file found, skipping.")
continue
if path.is_file():
path = cls._migrate_out_of_project_config_file(path)
if path is None:
continue
num_migrations += 1
project_config = ProjectConfig.load(path)
project_config = ProjectConfig.load(path, serena_config=instance) # instance is sufficiently populated
project = RegisteredProject(
project_root=str(path),
project_config=project_config,
@@ -750,7 +774,7 @@ class SerenaConfig(SharedConfig):
with open(path, "a", encoding=SERENA_FILE_ENCODING) as f:
f.write(f"\nproject_name: {project_name}")
project_root = project_config_data["project_root"]
shutil.move(str(path), str(Path(project_root) / ProjectConfig.rel_path_to_project_yml()))
shutil.move(str(path), ProjectConfig.default_project_yml_path(project_root))
return Path(project_root).resolve()
except Exception as e:
log.error(f"Error migrating configuration file: {e}")
@@ -789,9 +813,9 @@ class SerenaConfig(SharedConfig):
return project
# no registered project found; auto-register if project configuration exists
if autoregister:
config_path = ProjectConfig.path_to_project_yml(project_root_or_name)
config_path = self.get_project_yml_location(project_root_or_name)
if os.path.isfile(config_path):
registered_project = RegisteredProject.from_project_root(project_root_or_name)
registered_project = RegisteredProject.from_project_root(project_root_or_name, serena_config=self)
self.add_registered_project(registered_project)
return registered_project
# nothing found
@@ -834,7 +858,7 @@ class SerenaConfig(SharedConfig):
f"Project with path {project_root} was already added with name '{already_registered_project.project_name}'."
)
project_config = ProjectConfig.load(project_root, autogenerate=True)
project_config = ProjectConfig.load(project_root, serena_config=self, autogenerate=True)
new_project = Project(
project_root=str(project_root),
@@ -887,6 +911,78 @@ class SerenaConfig(SharedConfig):
save_yaml(self.config_file_path, commented_yaml)
@staticmethod
def _resolve_serena_folder_location(template: str, placeholders: dict[str, str]) -> str:
"""
Resolves a folder location template by replacing known ``$placeholder`` tokens
and raising on any unrecognised ones.
:param template: the template string (e.g. ``"$projectDir/.serena"``)
:param placeholders: mapping from placeholder name (without ``$``) to replacement value
:return: the resolved absolute path
:raises SerenaConfigError: if the template contains an unknown ``$placeholder``
"""
def _replace(match: re.Match[str]) -> str:
name = match.group(1)
if name not in placeholders:
raise SerenaConfigError(
f"Unknown placeholder '${name}' in project_serena_folder_location. "
f"Supported placeholders: {', '.join('$' + k for k in placeholders)}"
)
return placeholders[name]
result = re.sub(r"\$([A-Za-z_]\w*)", _replace, template)
return os.path.abspath(result)
def get_configured_project_serena_folder(self, project_root: str | Path) -> str:
"""
Returns the resolved absolute path to the .serena data folder for a project,
applying placeholder substitution to ``project_serena_folder_location``
without any fallback logic.
:param project_root: the absolute path to the project root directory
:return: the resolved absolute path to the project's .serena folder
:raises SerenaConfigError: if the template contains an unknown placeholder
"""
project_folder_name = Path(project_root).name
placeholders = {
"projectDir": str(project_root),
"projectFolderName": project_folder_name,
}
return self._resolve_serena_folder_location(self.project_serena_folder_location, placeholders)
def get_project_serena_folder(self, project_root: str | Path) -> str:
"""
Resolves the location of the project's .serena data folder using fallback logic:
1. If the folder exists at the configured path (``project_serena_folder_location``), use it.
2. Otherwise, if it exists at the default location inside the project root, use that.
3. If neither exists, return the configured path (for creation).
:param project_root: the absolute path to the project root directory
:return: the resolved absolute path to the .serena data folder
:raises SerenaConfigError: if the configured template contains an unknown placeholder
"""
configured_path = self.get_configured_project_serena_folder(project_root)
if os.path.isdir(configured_path):
return configured_path
default_path = os.path.join(str(project_root), SERENA_MANAGED_DIR_NAME)
if configured_path != default_path and os.path.isdir(default_path):
return default_path
return configured_path
def get_project_yml_location(self, project_root: str | Path) -> str:
"""
Returns the resolved absolute path to the project.yml configuration file,
based on the resolved .serena data folder (with fallback logic).
:param project_root: the absolute path to the project root directory
:return: the resolved absolute path to the project's project.yml file
"""
serena_folder = self.get_project_serena_folder(project_root)
return os.path.join(serena_folder, ProjectConfig.SERENA_DEFAULT_PROJECT_FILE)
def propagate_settings(self) -> None:
"""
Propagate settings from this configuration to individual components that are statically configured
+3 -2
View File
@@ -6,7 +6,6 @@ from collections.abc import Iterator
from sensai.util.logging import LogTime
from serena.config.serena_config import SerenaPaths
from serena.constants import SERENA_MANAGED_DIR_NAME
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language, LanguageServerConfig
from solidlsp.settings import SolidLSPSettings
@@ -23,6 +22,7 @@ class LanguageServerFactory:
def __init__(
self,
project_root: str,
project_data_path: str,
encoding: str,
ignored_patterns: list[str],
ls_timeout: float | None = None,
@@ -30,6 +30,7 @@ class LanguageServerFactory:
trace_lsp_communication: bool = False,
):
self.project_root = project_root
self.project_data_path = project_data_path
self.encoding = encoding
self.ignored_patterns = ignored_patterns
self.ls_timeout = ls_timeout
@@ -51,7 +52,7 @@ class LanguageServerFactory:
timeout=self.ls_timeout,
solidlsp_settings=SolidLSPSettings(
solidlsp_dir=SerenaPaths().serena_user_home_dir,
project_data_relative_path=SERENA_MANAGED_DIR_NAME,
project_data_path=self.project_data_path,
ls_specific_settings=self.ls_specific_settings or {},
),
)
+17 -16
View File
@@ -4,7 +4,7 @@ import os
import shutil
import threading
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
from typing import Any, Literal
import pathspec
from sensai.util.logging import LogTime
@@ -12,10 +12,10 @@ from sensai.util.string import ToStringMixin
from serena.config.serena_config import (
ProjectConfig,
SerenaConfig,
SerenaPaths,
get_serena_managed_in_project_dir,
)
from serena.constants import SERENA_FILE_ENCODING, SERENA_MANAGED_DIR_NAME
from serena.constants import SERENA_FILE_ENCODING
from serena.ls_manager import LanguageServerFactory, LanguageServerManager
from serena.util.file_system import GitignoreParser, match_path
from serena.util.text_utils import ContentReplacer, MatchedConsecutiveLines, search_files
@@ -23,9 +23,6 @@ from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_utils import FileUtils
if TYPE_CHECKING:
from serena.config.serena_config import SerenaConfig
log = logging.getLogger(__name__)
@@ -33,12 +30,12 @@ class MemoriesManager:
GLOBAL_TOPIC = "global"
_global_memory_dir = SerenaPaths().global_memories_path
def __init__(self, project_root: str, global_memory_tool_write_access: bool = False):
def __init__(self, serena_data_folder: str | Path, global_memory_tool_write_access: bool = False):
"""
:param project_root: the project's root directory
:param serena_data_folder: the absolute path to the project's .serena data folder
:param global_memory_tool_write_access: whether to allow writing global memories in tool execution contexts
"""
self._project_memory_dir = Path(get_serena_managed_in_project_dir(project_root)) / "memories"
self._project_memory_dir = Path(serena_data_folder) / "memories"
self._project_memory_dir.mkdir(parents=True, exist_ok=True)
self._global_memory_tool_write_access = global_memory_tool_write_access
self._encoding = SERENA_FILE_ENCODING
@@ -199,22 +196,24 @@ class Project(ToStringMixin):
*,
project_root: str,
project_config: ProjectConfig,
serena_config: "SerenaConfig",
serena_config: SerenaConfig,
is_newly_created: bool = False,
):
assert serena_config is not None
self.project_root = project_root
self.project_config = project_config
self._serena_config = serena_config
self._serena_data_folder = serena_config.get_project_serena_folder(self.project_root)
log.info("Serena project data folder: %s", self._serena_data_folder)
global_memory_write_access = serena_config.edit_global_memories if serena_config else False
self.memories_manager = MemoriesManager(project_root, global_memory_write_access)
self.memories_manager = MemoriesManager(self._serena_data_folder, global_memory_write_access)
self.language_server_manager: LanguageServerManager | None = None
self._is_newly_created = is_newly_created
# create .gitignore file in the project's Serena data folder if not yet present
serena_data_gitignore_path = os.path.join(self.path_to_serena_data_folder(), ".gitignore")
serena_data_gitignore_path = os.path.join(self._serena_data_folder, ".gitignore")
if not os.path.exists(serena_data_gitignore_path):
os.makedirs(os.path.dirname(serena_data_gitignore_path), exist_ok=True)
log.info(f"Creating .gitignore file in {serena_data_gitignore_path}")
@@ -276,23 +275,24 @@ class Project(ToStringMixin):
serena_config: "SerenaConfig",
autogenerate: bool = True,
) -> "Project":
assert serena_config is not None
project_root = Path(project_root).resolve()
if not project_root.exists():
raise FileNotFoundError(f"Project root not found: {project_root}")
project_config = ProjectConfig.load(project_root, autogenerate=autogenerate)
project_config = ProjectConfig.load(project_root, serena_config=serena_config, autogenerate=autogenerate)
return Project(project_root=str(project_root), project_config=project_config, serena_config=serena_config)
def save_config(self) -> None:
"""
Saves the current project configuration to disk.
"""
self.project_config.save(self.project_root)
self.project_config.save(self.path_to_project_yml())
def path_to_serena_data_folder(self) -> str:
return os.path.join(self.project_root, SERENA_MANAGED_DIR_NAME)
return self._serena_data_folder
def path_to_project_yml(self) -> str:
return os.path.join(self.project_root, self.project_config.rel_path_to_project_yml())
return os.path.join(self._serena_data_folder, ProjectConfig.SERENA_DEFAULT_PROJECT_FILE)
def get_activation_message(self) -> str:
"""
@@ -572,6 +572,7 @@ class Project(ToStringMixin):
log.info(f"Creating language server manager for {self.project_root}")
factory = LanguageServerFactory(
project_root=self.project_root,
project_data_path=self._serena_data_folder,
encoding=self.project_config.encoding,
ignored_patterns=self._ignored_patterns,
ls_timeout=ls_timeout,
@@ -126,5 +126,13 @@ token_count_estimator: CHAR_COUNT
# Can be overridden in project.yml.
symbol_info_budget: 10
# template for the location of the per-project .serena data folder (memories, caches, etc.).
# Supports the following placeholders:
# $projectDir - the absolute path to the project root directory
# $projectFolderName - the name of the project directory
# Default: "$projectDir/.serena" (data stored inside the project directory)
# Example for a central location: "/projects-metadata/$projectFolderName/.serena"
project_serena_folder_location: "$projectDir/.serena"
# the list of registered project paths (updated automatically).
projects: []
+1 -3
View File
@@ -477,9 +477,7 @@ class SolidLanguageServer(ABC):
self.language = Language(language_id)
# initialise symbol caches
self.cache_dir = (
Path(self.repository_root_path) / self._solidlsp_settings.project_data_relative_path / self.CACHE_FOLDER_NAME / self.language_id
)
self.cache_dir = Path(self._solidlsp_settings.project_data_path) / self.CACHE_FOLDER_NAME / self.language_id
self.cache_dir.mkdir(parents=True, exist_ok=True)
# * raw document symbols cache
self._ls_specific_raw_document_symbols_cache_version = cache_version_raw_document_symbols
+4 -4
View File
@@ -22,11 +22,11 @@ class SolidLSPSettings:
"""
Path to the directory in which to store global Solid-LSP data (which is not project-specific)
"""
project_data_relative_path: str = ".solidlsp"
project_data_path: str = ""
"""
Relative path within each project directory where Solid-LSP can store project-specific data, e.g. cache files.
For instance, if this is ".solidlsp" and the project is located at "/home/user/myproject",
then Solid-LSP will store project-specific data in "/home/user/myproject/.solidlsp".
Absolute path to a directory where Solid-LSP can store project-specific data, e.g. cache files.
For instance, if this is "/home/user/myproject/.solidlsp",
then Solid-LSP will store project-specific data (e.g. caches) in that directory.
"""
ls_specific_settings: dict["Language", dict[str, Any]] = field(default_factory=dict)
"""
+7 -3
View File
@@ -69,12 +69,13 @@ def _create_ls(
trace_lsp_communication=trace_lsp_communication,
)
effective_solidlsp_dir = solidlsp_dir if solidlsp_dir is not None else SerenaPaths().serena_user_home_dir
project_data_path = os.path.join(repo_path, SERENA_MANAGED_DIR_NAME)
return SolidLanguageServer.create(
config,
repo_path,
solidlsp_settings=SolidLSPSettings(
solidlsp_dir=effective_solidlsp_dir,
project_data_relative_path=SERENA_MANAGED_DIR_NAME,
project_data_path=project_data_path,
ls_specific_settings=ls_specific_settings or {},
),
)
@@ -115,10 +116,13 @@ def start_default_ls_context(language: Language) -> Iterator[SolidLanguageServer
yield ls
def create_default_serena_config():
return SerenaConfig(gui_log_window=False, web_dashboard=False)
def _create_default_project(language: Language) -> Project:
repo_path = str(get_repo_path(language))
serena_config = SerenaConfig()
return Project.load(repo_path, serena_config=serena_config)
return Project.load(repo_path, serena_config=create_default_serena_config())
@pytest.fixture(scope="session")
@@ -159,6 +159,7 @@ class TestRegisteredProjectGlobalIgnoredPaths:
serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"])
registered = RegisteredProject.from_project_root(
str(self.project_path),
serena_config=serena_config,
)
project = registered.get_project_instance(serena_config=serena_config)
assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js"))
+213 -11
View File
@@ -1,4 +1,5 @@
import logging
import os
import shutil
import tempfile
from pathlib import Path
@@ -6,10 +7,18 @@ from pathlib import Path
import pytest
from serena.agent import SerenaAgent
from serena.config.serena_config import LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig
from serena.constants import PROJECT_TEMPLATE_FILE
from serena.project import Project
from serena.config.serena_config import (
DEFAULT_PROJECT_SERENA_FOLDER_LOCATION,
LanguageBackend,
ProjectConfig,
RegisteredProject,
SerenaConfig,
SerenaConfigError,
)
from serena.constants import PROJECT_TEMPLATE_FILE, SERENA_MANAGED_DIR_NAME
from serena.project import MemoriesManager, Project
from solidlsp.ls_config import Language
from test.conftest import create_default_serena_config
class TestProjectConfigAutogenerate:
@@ -19,6 +28,7 @@ class TestProjectConfigAutogenerate:
"""Set up test environment before each test method."""
# Create a temporary directory for testing
self.test_dir = tempfile.mkdtemp()
self.serena_config = create_default_serena_config()
self.project_path = Path(self.test_dir)
def teardown_method(self):
@@ -29,7 +39,7 @@ class TestProjectConfigAutogenerate:
def test_autogenerate_empty_directory(self):
"""Test that autogenerate raises ValueError with helpful message for empty directory."""
with pytest.raises(ValueError) as exc_info:
ProjectConfig.autogenerate(self.project_path, save_to_disk=False)
ProjectConfig.autogenerate(self.project_path, self.serena_config, save_to_disk=False)
error_message = str(exc_info.value)
assert "No source files found" in error_message
@@ -41,7 +51,7 @@ class TestProjectConfigAutogenerate:
python_file.write_text("def hello():\n print('Hello, world!')\n")
# Run autogenerate
config = ProjectConfig.autogenerate(self.project_path, save_to_disk=False)
config = ProjectConfig.autogenerate(self.project_path, self.serena_config, save_to_disk=False)
# Verify the configuration
assert config.project_name == self.project_path.name
@@ -53,7 +63,7 @@ class TestProjectConfigAutogenerate:
(self.project_path / "small.js").write_text("console.log('JS');")
# Run autogenerate - should pick Python as dominant
config = ProjectConfig.autogenerate(self.project_path, save_to_disk=False)
config = ProjectConfig.autogenerate(self.project_path, self.serena_config, save_to_disk=False)
assert config.languages == [Language.TYPESCRIPT]
@@ -65,7 +75,7 @@ class TestProjectConfigAutogenerate:
(self.project_path / "small.js").write_text("console.log('JS');")
# Run autogenerate - should pick Python as dominant
config = ProjectConfig.autogenerate(self.project_path, save_to_disk=False)
config = ProjectConfig.autogenerate(self.project_path, self.serena_config, save_to_disk=False)
assert config.languages == [Language.PYTHON]
@@ -76,7 +86,7 @@ class TestProjectConfigAutogenerate:
go_file.write_text("package main\n\nfunc main() {}\n")
# Run autogenerate with save_to_disk=True
config = ProjectConfig.autogenerate(self.project_path, save_to_disk=True)
config = ProjectConfig.autogenerate(self.project_path, self.serena_config, save_to_disk=True)
# Verify the configuration file was created
config_path = self.project_path / ".serena" / "project.yml"
@@ -90,7 +100,7 @@ class TestProjectConfigAutogenerate:
non_existent = self.project_path / "does_not_exist"
with pytest.raises(FileNotFoundError) as exc_info:
ProjectConfig.autogenerate(non_existent, save_to_disk=False)
ProjectConfig.autogenerate(non_existent, self.serena_config, save_to_disk=False)
assert "Project root not found" in str(exc_info.value)
@@ -105,7 +115,7 @@ class TestProjectConfigAutogenerate:
# Should still raise ValueError as no source files are detected
with pytest.raises(ValueError) as exc_info:
ProjectConfig.autogenerate(self.project_path, save_to_disk=False)
ProjectConfig.autogenerate(self.project_path, self.serena_config, save_to_disk=False)
assert "No source files found" in str(exc_info.value)
@@ -117,7 +127,7 @@ class TestProjectConfigAutogenerate:
# Run autogenerate with custom name
custom_name = "my-custom-project"
config = ProjectConfig.autogenerate(self.project_path, project_name=custom_name, save_to_disk=False)
config = ProjectConfig.autogenerate(self.project_path, self.serena_config, project_name=custom_name, save_to_disk=False)
assert config.project_name == custom_name
assert config.languages == [Language.TYPESCRIPT]
@@ -316,3 +326,195 @@ class TestEffectiveLanguageBackend:
agent.activate_project_from_path_or_name("proj2")
finally:
agent.shutdown(timeout=5)
class TestGetConfiguredProjectSerenaFolder:
"""Tests for SerenaConfig.get_configured_project_serena_folder (pure template resolution)."""
def test_default_location(self):
config = SerenaConfig(
gui_log_window=False,
web_dashboard=False,
)
result = config.get_configured_project_serena_folder("/home/user/myproject")
assert result == os.path.abspath("/home/user/myproject/.serena")
def test_custom_location_with_project_folder_name(self):
config = SerenaConfig(
gui_log_window=False,
web_dashboard=False,
project_serena_folder_location="/projects-metadata/$projectFolderName/.serena",
)
result = config.get_configured_project_serena_folder("/home/user/myproject")
assert result == os.path.abspath("/projects-metadata/myproject/.serena")
def test_custom_location_with_project_dir(self):
config = SerenaConfig(
gui_log_window=False,
web_dashboard=False,
project_serena_folder_location="$projectDir/.custom-serena",
)
result = config.get_configured_project_serena_folder("/home/user/myproject")
assert result == os.path.abspath("/home/user/myproject/.custom-serena")
def test_custom_location_with_both_placeholders(self):
config = SerenaConfig(
gui_log_window=False,
web_dashboard=False,
project_serena_folder_location="/data/$projectFolderName/$projectDir/.serena",
)
result = config.get_configured_project_serena_folder("/home/user/proj")
assert result == os.path.abspath("/data/proj/home/user/proj/.serena")
def test_default_field_value(self):
config = SerenaConfig(
gui_log_window=False,
web_dashboard=False,
)
assert config.project_serena_folder_location == DEFAULT_PROJECT_SERENA_FOLDER_LOCATION
def test_rejects_unknown_placeholder(self):
config = SerenaConfig(
gui_log_window=False,
web_dashboard=False,
project_serena_folder_location="$projectDir/$unknownVar/.serena",
)
with pytest.raises(SerenaConfigError, match=r"Unknown placeholder '\$unknownVar'"):
config.get_configured_project_serena_folder("/home/user/myproject")
def test_rejects_typo_projectDirs(self):
"""$projectDirs should not be silently treated as $projectDir + 's'."""
config = SerenaConfig(
gui_log_window=False,
web_dashboard=False,
project_serena_folder_location="$projectDirs/.serena",
)
with pytest.raises(SerenaConfigError, match=r"Unknown placeholder '\$projectDirs'"):
config.get_configured_project_serena_folder("/home/user/myproject")
def test_rejects_typo_projectfoldername_lowercase(self):
config = SerenaConfig(
gui_log_window=False,
web_dashboard=False,
project_serena_folder_location="/data/$projectfoldername/.serena",
)
with pytest.raises(SerenaConfigError, match=r"Unknown placeholder '\$projectfoldername'"):
config.get_configured_project_serena_folder("/home/user/myproject")
def test_no_placeholders_is_valid(self):
config = SerenaConfig(
gui_log_window=False,
web_dashboard=False,
project_serena_folder_location="/fixed/path/.serena",
)
result = config.get_configured_project_serena_folder("/home/user/myproject")
assert result == os.path.abspath("/fixed/path/.serena")
def test_error_message_lists_supported_placeholders(self):
config = SerenaConfig(
gui_log_window=False,
web_dashboard=False,
project_serena_folder_location="$bogus/.serena",
)
with pytest.raises(SerenaConfigError, match=r"\$projectDir.*\$projectFolderName|\$projectFolderName.*\$projectDir"):
config.get_configured_project_serena_folder("/home/user/myproject")
class TestProjectSerenaDataFolder:
"""Tests for SerenaConfig.get_project_serena_folder fallback logic (via Project)."""
def setup_method(self):
self.test_dir = tempfile.mkdtemp()
self.project_path = Path(self.test_dir) / "myproject"
self.project_path.mkdir()
(self.project_path / "main.py").write_text("print('hello')\n")
def teardown_method(self):
shutil.rmtree(self.test_dir)
def _make_project(self, serena_config: "SerenaConfig | None" = None) -> Project:
project_config = ProjectConfig(
project_name="myproject",
languages=[Language.PYTHON],
)
return Project(
project_root=str(self.project_path),
project_config=project_config,
serena_config=serena_config,
)
def test_default_config_creates_in_project_dir(self):
config = SerenaConfig(gui_log_window=False, web_dashboard=False)
project = self._make_project(config)
expected = os.path.abspath(str(self.project_path / SERENA_MANAGED_DIR_NAME))
assert project.path_to_serena_data_folder() == expected
def test_custom_location_creates_outside_project(self):
custom_base = Path(self.test_dir) / "metadata"
custom_base.mkdir()
config = SerenaConfig(
gui_log_window=False,
web_dashboard=False,
project_serena_folder_location=str(custom_base) + "/$projectFolderName/.serena",
)
project = self._make_project(config)
expected = os.path.abspath(str(custom_base / "myproject" / ".serena"))
assert project.path_to_serena_data_folder() == expected
def test_fallback_to_existing_project_dir(self):
"""If config points to a non-existent path but .serena exists in the project root, use the existing one."""
existing_serena = self.project_path / SERENA_MANAGED_DIR_NAME
existing_serena.mkdir()
config = SerenaConfig(
gui_log_window=False,
web_dashboard=False,
project_serena_folder_location="/nonexistent/path/$projectFolderName/.serena",
)
project = self._make_project(config)
assert project.path_to_serena_data_folder() == str(existing_serena)
def test_configured_path_takes_precedence_when_exists(self):
"""If both config path and project root path exist, use the config path."""
existing_serena = self.project_path / SERENA_MANAGED_DIR_NAME
existing_serena.mkdir()
custom_base = Path(self.test_dir) / "metadata"
custom_serena = custom_base / "myproject" / ".serena"
custom_serena.mkdir(parents=True)
config = SerenaConfig(
gui_log_window=False,
web_dashboard=False,
project_serena_folder_location=str(custom_base) + "/$projectFolderName/.serena",
)
project = self._make_project(config)
assert project.path_to_serena_data_folder() == str(custom_serena)
class TestMemoriesManagerCustomPath:
"""Tests for MemoriesManager with a custom serena data folder."""
def setup_method(self):
self.test_dir = tempfile.mkdtemp()
self.data_folder = Path(self.test_dir) / "custom_serena"
def teardown_method(self):
shutil.rmtree(self.test_dir)
def test_memories_subdir_is_created(self):
assert not self.data_folder.exists()
MemoriesManager(str(self.data_folder))
assert (self.data_folder / "memories").exists()
def test_save_and_load_memory(self):
manager = MemoriesManager(str(self.data_folder))
manager.save_memory("test_topic", "test content", is_tool_context=False)
content = manager.load_memory("test_topic")
assert content == "test content"
def test_list_memories(self):
manager = MemoriesManager(str(self.data_folder))
manager.save_memory("topic_a", "content a", is_tool_context=False)
manager.save_memory("topic_b", "content b", is_tool_context=False)
memories = manager.list_project_memories()
assert sorted(memories) == ["topic_a", "topic_b"]
+2 -2
View File
@@ -302,7 +302,7 @@ class TestCSharpSolutionProjectOpening:
# Create CSharpLanguageServer instance
mock_settings = Mock(spec=SolidLSPSettings)
mock_settings.ls_resources_dir = "/tmp/test_ls_resources"
mock_settings.project_data_relative_path = "project_data"
mock_settings.project_data_path = str(temp_path / "project_data")
with SuspendedLoggersContext():
logging.getLogger().setLevel(logging.DEBUG)
@@ -330,7 +330,7 @@ class TestCSharpSolutionProjectOpening:
mock_settings = Mock(spec=SolidLSPSettings)
mock_settings.ls_resources_dir = "/tmp/test_ls_resources"
mock_settings.project_data_relative_path = "project_data"
mock_settings.project_data_path = str(temp_path / "project_data")
# Create CSharpLanguageServer instance
with SuspendedLoggersContext():
+2 -1
View File
@@ -43,9 +43,10 @@ class TestGroovyLanguageServer:
repo_path = str(cls.test_repo_path)
config = LanguageServerConfig(code_language=Language.GROOVY, ignored_paths=[], trace_lsp_communication=False)
project_data_path = os.path.join(repo_path, SERENA_MANAGED_DIR_NAME)
solidlsp_settings = SolidLSPSettings(
solidlsp_dir=str(Path.home() / ".serena"),
project_data_relative_path=SERENA_MANAGED_DIR_NAME,
project_data_path=project_data_path,
ls_specific_settings={Language.GROOVY: groovy_settings},
)