Merge branch 'refs/heads/main' into read_only_memories

# Conflicts:
#	src/serena/config/serena_config.py
#	src/serena/project.py
This commit is contained in:
Michael Panchenko authored and Dominik Jain committed 2026-03-06 23:23:11 +01:00
commit 57fc582606
26 files changed
+928 -194

No files matched your search

+16 -1
View File
@@ -90,7 +90,22 @@ included_optional_tools: []
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
initial_prompt: |
IMPORTANT: You use an idiomatic, object-oriented style.
In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions
rather than mere functions (i.e. use the strategy pattern, for example).
You avoid the use of low-level data structures in all cases where an object-oriented abstraction would be more appropriate.
For simple data storage, you use dataclasses instead of dictionaries or tuples.
You structure function implementations into functional blocks that are separated by blank lines.
Atop each functional block, you write an elliptical phrase (starting with lower-case letter) that describes the purpose of the
block in a concise manner.
Docstrings: You consistently use reStructuredText.
Comments:
When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase
clearly defines *what* it is. Any details then follow in subsequent sentences.
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
+4
View File
@@ -7,6 +7,10 @@ Status of the `main` branch. Changes prior to the next official version change w
* Project configuration files (`project.yml`) can now define multiple languages.
Auto-detection adds only the most prominent language by default.
* Additional languages can be conveniently added via the Dashboard while a project is already activated.
* Add support for querying projects other than the currently active one via new tools `QueryProjectTool` and `ListQueryableProjectsTool`.
The `QueryProjectTool` allows Serena tools to be called on other projects.
* For the LSP backend, calling symbolic tools require a project server to be spawned that will launch the respective language servers
* For the JetBrains backend, all projects for which IDE instances are open can directly be queried
* The current project can be activated automatically even when the MCP configuration is global (through the --project-from-cwd flag)
* Support overloaded symbols in `FindSymbolTool` and related tools
* Name paths of overloaded symbols now include an index (e.g., `myOverloadedFunction[2]`)
+40 -2
View File
@@ -36,7 +36,7 @@ For instance, when using `uvx`, run
* For an existing project, the main programming language will be detected automatically,
but you can choose to explicitly specify multiple languages by passing the `--language` parameter
multiple times (e.g. `--language python --language typescript`).
* You can optionally specify a custom project name with `--name "My Project"`.
* You can optionally specify a custom project name with `--name my-name`.
* You can immediately index the project after creation with `--index`.
(project-config)=
@@ -153,4 +153,42 @@ linting results, and test results to assess the correctness of its actions.
Therefore, software that is designed to meaningful interpretable outputs (e.g. log messages)
and that has a good test coverage is much easier to work with for Serena.
We generally recommend to start an editing task from a state where all linting checks and tests pass.
We generally recommend to start an editing task from a state where all linting checks and tests pass.
## Working with Multiple Projects Simultaneously
There are several ways in which you might want to work with multiple projects simultaneously.
### Simultaneously Editing in Multiple Projects
If fulfilling a task requires the agent to edit code in multiple projects, the recommended approach is to create a **monorepo folder**,
i.e. a folder that contains all the projects as sub-folders, and open that monorepo folder as a project in Serena.
You may also use symbolic links to create a monorepo folder if the projects are located in different places on your filesystem.
If several languages are used across the projects, specify all of them as needed when using the LSP backend;
For JetBrains mode, make sure that your IDE is configured to work with all the languages used across the projects (e.g. by installing the respective language plugins).
(query-projects)=
### Reading from External Projects
If, while working on a project, you want Serena to be able to read code or other information from another project (e.g. a library or otherwise related project),
this can be enabled via the `query_project` tool.
Provided that the project you want to query is known to Serena (i.e. you have created it as described above),
the `query_project` tool allows the agent to query files and symbolic information from that project.
To enable this tool, [activate the mode](modes) `query-projects`.
This also enables a second tool for listing projects that can be queried.
Depending on the language backend being used, the management of resources for the external projects varies:
* When using the JetBrains backend, make sure that every project for which you want symbolic queries to work is open in an IDE instance.
* When using the LSP backend, executing symbolic tools via the query tool requires that Serena's **Project Server** be started,
which will automatically spawn the necessary language servers for the projects that are queried.
To start the server, run
<serena> start-project-server
where `<serena>` is your way of running Serena. For example, when using `uvx`, run
uvx --from git+https://github.com/oraios/serena serena start-project-server
+31 -1
View File
@@ -113,7 +113,8 @@ Examples of built-in modes include:
* `one-shot`: Configures Serena for tasks that should be completed in a single response, often used with `planning` for generating reports or initial plans.
* `no-onboarding`: Skips the initial onboarding process if it's not needed for a particular session but retains the memory tools (assuming initial memories were created externally).
* `onboarding`: Focuses on the project onboarding process.
* `no-memories`: Disables all memory tools (and tools building on memories such as onboarding tools)
* `no-memories`: Disables all memory tools (and tools building on memories such as onboarding tools)
* `query-projects`: Enables tools for querying other Serena projects (without activating them); see section [Reading from External Projects](query-projects)
Find the concrete definitions of these modes [here](https://github.com/oraios/serena/tree/main/src/serena/resources/config/modes).
@@ -172,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
+19 -27
View File
@@ -6,7 +6,8 @@ import os
import platform
import subprocess
import sys
from collections.abc import Callable, Sequence
from collections.abc import Callable, Iterator, Sequence
from contextlib import contextmanager
from logging import Logger
from typing import TYPE_CHECKING, Optional, TypeVar
@@ -551,15 +552,6 @@ class SerenaAgent:
)
return True
def get_project_root(self) -> str:
"""
:return: the root directory of the active project (if any); raises a ValueError if there is no active project
"""
project = self.get_active_project()
if project is None:
raise ValueError("Cannot get project root if no project is active.")
return project.project_root
def get_exposed_tool_instances(self) -> list["Tool"]:
"""
:return: the tool instances which are exposed (e.g. to the MCP client).
@@ -612,7 +604,7 @@ class SerenaAgent:
available_tools = self._active_tools
available_markers = available_tools.tool_marker_names
global_memory_names = MemoriesManager(
project_root=None, read_only_memory_patterns=self.serena_config.read_only_memory_patterns
serena_data_folder=None, read_only_memory_patterns=self.serena_config.read_only_memory_patterns
).list_global_memories()
global_memories_list = list_string(global_memory_names) if global_memory_names else ""
log.info("Generating system prompt with available_tools=(see active tools), available_markers=%s", available_markers)
@@ -731,7 +723,7 @@ class SerenaAgent:
self.reset_language_server_manager()
# initialize the language server in the background (if in language server mode)
if self.is_using_language_server():
if self.get_language_backend().is_lsp():
self.issue_task(init_language_server_manager)
if self._project_activation_callback is not None:
@@ -828,23 +820,9 @@ class SerenaAgent:
"""
Starts/resets the language server manager for the current project
"""
tool_timeout = self.serena_config.tool_timeout
if tool_timeout is None or tool_timeout < 0:
ls_timeout = None
else:
if tool_timeout < 10:
raise ValueError(f"Tool timeout must be at least 10 seconds, but is {tool_timeout} seconds")
ls_timeout = tool_timeout - 5 # the LS timeout is for a single call, it should be smaller than the tool timeout
# instantiate and start the necessary language servers
try:
self._lsp_init_error = None
self.get_active_project_or_raise().create_language_server_manager(
log_level=self.serena_config.log_level,
ls_timeout=ls_timeout,
trace_lsp_communication=self.serena_config.trace_lsp_communication,
ls_specific_settings=self.serena_config.ls_specific_settings,
)
self.get_active_project_or_raise().create_language_server_manager()
except LanguageServerManagerInitialisationError as e:
self._lsp_init_error = e
raise
@@ -901,3 +879,17 @@ class SerenaAgent:
if ls_manager is None:
return []
return ls_manager.get_active_languages()
@contextmanager
def active_project_context(self, project: Project) -> Iterator[None]:
"""
Context manager for temporarily setting/overriding the active project
:param project: the project to be active
"""
original_project = self._active_project
self._active_project = project
try:
yield
finally:
self._active_project = original_project
+63 -9
View File
@@ -350,6 +350,61 @@ class TopLevelCommands(AutoRegisteringGroup):
else:
print(f"{prefix}\n{instr}\n{postfix}")
@staticmethod
@click.command(
"start-project-server",
help="Starts the Serena project server, which exposes project querying capabilities via HTTP.",
context_settings={"max_content_width": _MAX_CONTENT_WIDTH},
)
@click.option(
"--host",
type=str,
default="127.0.0.1",
show_default=True,
help="Listen address for the project server.",
)
@click.option(
"--port",
type=int,
default=None,
help="Listen port for the project server (default: ProjectServer.PORT).",
)
@click.option(
"--log-level",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
default=None,
help="Override log level in config.",
)
def start_project_server(
host: str,
port: int | None,
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None,
) -> None:
from serena.project_server import ProjectServer
# initialize logging
Logger.root.setLevel(logging.INFO)
formatter = logging.Formatter(SERENA_LOG_FORMAT)
stderr_handler = logging.StreamHandler(stream=sys.stderr)
stderr_handler.formatter = formatter
Logger.root.addHandler(stderr_handler)
log_path = SerenaPaths().get_next_log_file_path("project-server")
file_handler = logging.FileHandler(log_path, mode="w")
file_handler.formatter = formatter
Logger.root.addHandler(file_handler)
if log_level is not None:
Logger.root.setLevel(logging.getLevelNamesMapping()[log_level])
log.info("Starting Serena project server")
log.info("Storing logs in %s", log_path)
server = ProjectServer()
run_kwargs: dict[str, Any] = {"host": host}
if port is not None:
run_kwargs["port"] = port
server.run(**run_kwargs)
class ModeCommands(AutoRegisteringGroup):
"""Group for 'mode' subcommands."""
@@ -544,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.")
@@ -558,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)
@@ -640,9 +696,7 @@ class ProjectCommands(AutoRegisteringGroup):
serena_config = SerenaConfig.from_config_file()
proj = registered_project.get_project_instance(serena_config=serena_config)
click.echo(f"Indexing symbols in {proj} …")
ls_mgr = proj.create_language_server_manager(
log_level=lvl, ls_timeout=timeout, ls_specific_settings=serena_config.ls_specific_settings
)
ls_mgr = proj.create_language_server_manager()
try:
log_file = os.path.join(proj.project_root, ".serena", "logs", "indexing.txt")
+9 -18
View File
@@ -4,8 +4,9 @@ import os
from abc import ABC, abstractmethod
from collections.abc import Iterable, Iterator, Reversible
from contextlib import contextmanager
from typing import TYPE_CHECKING, Generic, Optional, TypeVar, cast
from typing import Generic, TypeVar, cast
from serena.config.serena_config import ProjectConfig
from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient
from serena.symbol import JetBrainsSymbol, LanguageServerSymbol, LanguageServerSymbolRetriever, PositionInFile, Symbol
from solidlsp import SolidLanguageServer, ls_types
@@ -15,26 +16,16 @@ from solidlsp.ls_utils import PathUtils, TextUtils
from .constants import DEFAULT_SOURCE_FILE_ENCODING
from .project import Project
if TYPE_CHECKING:
from .agent import SerenaAgent
log = logging.getLogger(__name__)
TSymbol = TypeVar("TSymbol", bound=Symbol)
class CodeEditor(Generic[TSymbol], ABC):
def __init__(self, project_root: str, agent: Optional["SerenaAgent"] = None) -> None:
def __init__(self, project_root: str, project_config: ProjectConfig | None = None) -> None:
self.project_root = project_root
self.agent = agent
# set encoding based on active project, if available
encoding = DEFAULT_SOURCE_FILE_ENCODING
if agent is not None:
project = agent.get_active_project()
if project is not None:
encoding = project.project_config.encoding
self.encoding = encoding
# set encoding based on project configuration, if available
self.encoding = project_config.encoding if project_config is not None else DEFAULT_SOURCE_FILE_ENCODING
class EditedFile(ABC):
def __init__(self, relative_path: str) -> None:
@@ -244,8 +235,8 @@ class CodeEditor(Generic[TSymbol], ABC):
class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]):
def __init__(self, symbol_retriever: LanguageServerSymbolRetriever, agent: Optional["SerenaAgent"] = None):
super().__init__(project_root=symbol_retriever.get_root_path(), agent=agent)
def __init__(self, symbol_retriever: LanguageServerSymbolRetriever, project_config: ProjectConfig | None = None):
super().__init__(project_root=symbol_retriever.get_root_path(), project_config=project_config)
self._symbol_retriever = symbol_retriever
def _get_language_server(self, relative_path: str) -> SolidLanguageServer:
@@ -379,9 +370,9 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]):
class JetBrainsCodeEditor(CodeEditor[JetBrainsSymbol]):
def __init__(self, project: Project, agent: Optional["SerenaAgent"] = None) -> None:
def __init__(self, project: Project) -> None:
self._project = project
super().__init__(project_root=project.project_root, agent=agent)
super().__init__(project_root=project.project_root, project_config=project.project_config)
class EditedFile(CodeEditor.EditedFile):
def __init__(self, relative_path: str, project: Project):
+132 -30
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
@@ -173,6 +174,12 @@ class LanguageBackend(Enum):
return backend
raise ValueError(f"Unknown language backend '{backend_str}': valid values are {[b.value for b in LanguageBackend]}")
def is_lsp(self) -> bool:
return self == LanguageBackend.LSP
def is_jetbrains(self) -> bool:
return self == LanguageBackend.JETBRAINS
@dataclass
class SharedConfig(ModeSelectionDefinition, ToolInclusionDefinition, ToStringMixin):
@@ -190,8 +197,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)
@@ -219,6 +229,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,
@@ -228,6 +239,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
@@ -240,7 +252,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")
@@ -288,18 +301,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(
@@ -397,24 +411,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)
@@ -422,17 +441,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
@@ -480,8 +499,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,
@@ -496,7 +515,7 @@ class RegisteredProject(ToStringMixin):
"""
return self.project_root.samefile(Path(path).resolve())
def get_project_instance(self, serena_config: "SerenaConfig | None") -> "Project":
def get_project_instance(self, serena_config: "SerenaConfig") -> "Project":
"""
Returns the project instance for this registered project, loading it if necessary.
"""
@@ -557,6 +576,17 @@ class SerenaConfig(SharedConfig):
"""List of paths to ignore across all projects. Same syntax as gitignore, so you can use * and **.
These patterns are merged additively with each project's own ignored_paths."""
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
@@ -682,15 +712,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,
@@ -743,7 +773,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}")
@@ -772,7 +802,7 @@ class SerenaConfig(SharedConfig):
return project_candidates[0]
elif len(project_candidates) > 1:
raise ValueError(
f"Multiple projects found with name '{project_root_or_name}'. Please activate it by location instead. "
f"Multiple projects found with name '{project_root_or_name}'. Please reference it by location instead. "
f"Locations: {[p.project_root for p in project_candidates]}"
)
# no project found by name; check if it's a path
@@ -782,9 +812,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
@@ -827,7 +857,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),
@@ -880,6 +910,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
+1 -1
View File
@@ -524,7 +524,7 @@ class SerenaDashboardAPI:
available_modes=available_modes,
available_contexts=available_contexts,
available_memories=available_memories,
jetbrains_mode=not self._agent.is_using_language_server(),
jetbrains_mode=self._agent.get_language_backend().is_jetbrains(),
languages=languages,
encoding=encoding,
current_client=Tool.get_last_tool_call_client_str(),
@@ -8,6 +8,7 @@ import logging
import re
import threading
from concurrent.futures.thread import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal, Optional, Self, TypeVar, cast
@@ -16,6 +17,7 @@ from requests import Response
from sensai.util.string import ToStringMixin
import serena.jetbrains.jetbrains_types as jb
from serena.config.serena_config import RegisteredProject
from serena.jetbrains.jetbrains_types import PluginStatusDTO
from serena.project import Project
from serena.util.class_decorators import singleton
@@ -63,6 +65,12 @@ class ServerNotFoundError(Exception):
"""Raised when the plugin's service is not found."""
@dataclass
class MatchedClient:
client: "JetBrainsPluginClient"
registered_project: RegisteredProject
@singleton
class JetBrainsPluginClientManager:
"""
@@ -73,6 +81,7 @@ class JetBrainsPluginClientManager:
def __init__(self) -> None:
self._clients: dict[int, "JetBrainsPluginClient"] = {}
self._matched_clients: list[MatchedClient] = []
self._lock = threading.Lock()
def _submit_scan(self) -> list[concurrent.futures.Future["JetBrainsPluginClient"]]:
@@ -116,6 +125,47 @@ class JetBrainsPluginClientManager:
"with the Serena plugin installed and running!"
)
def match_clients(self, registered_projects: list[RegisteredProject]) -> list[MatchedClient]:
"""
Scans for plugin instances and matches them against the given registered projects.
:param registered_projects: the list of registered projects to match plugin instances against
:return: the list of matched clients with their corresponding registered project
"""
matched_clients = []
for future in self._submit_scan():
client = future.result()
if client.project_root is not None:
for rp in registered_projects:
if client.matches(Path(rp.project_root)):
matched_clients.append(MatchedClient(client, rp))
break
self._matched_clients = matched_clients
return matched_clients
def get_matched_client(
self, registered_project: RegisteredProject, registered_projects: list[RegisteredProject]
) -> Optional["JetBrainsPluginClient"]:
"""
Gets the matched client for a given registered project, if any.
:param registered_project: the registered project to get the matched client for
:param registered_projects: the list of all registered projects (used to perform matching of all clients
if no match is found for the given project)
:return: the matched client or None if no match is found
"""
def find_match() -> Optional["JetBrainsPluginClient"]:
for matched_client in self._matched_clients:
if matched_client.registered_project.project_root == registered_project.project_root:
return matched_client.client
return None
match = find_match()
if match is None:
self.match_clients(registered_projects)
return find_match()
class JetBrainsPluginClient(ToStringMixin):
"""
+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 {},
),
)
+38 -40
View File
@@ -6,19 +6,18 @@ import shutil
import threading
from collections.abc import Sequence
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
from typing import Any, Literal
import pathspec
from sensai.util.logging import LogTime
from sensai.util.string import ToStringMixin
from serena.config.serena_config import (
DEFAULT_TOOL_TIMEOUT,
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
@@ -26,9 +25,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__)
@@ -36,17 +32,17 @@ class MemoriesManager:
GLOBAL_TOPIC = "global"
_global_memory_dir = SerenaPaths().global_memories_path
def __init__(self, project_root: str | None, read_only_memory_patterns: Sequence[str] = ()):
def __init__(self, serena_data_folder: str | Path | None, read_only_memory_patterns: Sequence[str] = ()):
"""
:param project_root: the project's root directory. We allow
:param serena_data_folder: the absolute path to the project's .serena data folder
:param read_only_memory_patterns: whether to allow writing global memories in tool execution contexts
"""
self._project_memory_dir: Path | None = None
if project_root is not None:
self._project_memory_dir = Path(get_serena_managed_in_project_dir(project_root)) / "memories"
if serena_data_folder is not None:
self._project_memory_dir = Path(serena_data_folder) / "memories"
self._project_memory_dir.mkdir(parents=True, exist_ok=True)
self._encoding = SERENA_FILE_ENCODING
self._read_only_memory_patterns = [re.compile(pattern) for pattern in read_only_memory_patterns]
self._read_only_memory_patterns = [re.compile(pattern) for pattern in set(read_only_memory_patterns)]
def _is_read_only_memory(self, name: str) -> bool:
for read_only_memory in self._read_only_memory_patterns:
@@ -210,24 +206,27 @@ class MemoriesManager:
class Project(ToStringMixin):
def __init__(
self,
*,
project_root: str,
project_config: ProjectConfig,
serena_config: SerenaConfig,
is_newly_created: bool = False,
serena_config: "SerenaConfig | None" = None,
):
assert serena_config is not None
self.project_root = project_root
self.project_config = project_config
if serena_config is not None:
read_only_memory_patterns = serena_config.read_only_memory_patterns + project_config.read_only_memory_patterns
else:
read_only_memory_patterns = project_config.read_only_memory_patterns
self.memories_manager = MemoriesManager(project_root, read_only_memory_patterns=read_only_memory_patterns)
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)
read_only_memory_patterns = project_config.read_only_memory_patterns
self.memories_manager = MemoriesManager(self._serena_data_folder, read_only_memory_patterns=read_only_memory_patterns)
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}")
@@ -235,7 +234,6 @@ class Project(ToStringMixin):
f.write(f"/{SolidLanguageServer.CACHE_FOLDER_NAME}\n")
# prepare ignore spec asynchronously, ensuring immediate project activation.
self._serena_config = serena_config
self.__ignored_patterns: list[str]
self.__ignore_spec: pathspec.PathSpec
self._ignore_spec_available = threading.Event()
@@ -245,7 +243,7 @@ class Project(ToStringMixin):
with LogTime(f"Gathering ignore spec for project {self.project_config.project_name}", logger=log):
# gather ignored paths from the global configuration, project configuration, and gitignore files
global_ignored_paths = self._serena_config.ignored_paths if self._serena_config else []
global_ignored_paths = self._serena_config.ignored_paths
ignored_patterns = list(global_ignored_paths) + list(self.project_config.ignored_paths)
if len(global_ignored_paths) > 0:
log.info(f"Using {len(global_ignored_paths)} ignored paths from the global configuration.")
@@ -287,26 +285,27 @@ class Project(ToStringMixin):
def load(
cls,
project_root: str | Path,
serena_config: "SerenaConfig | None",
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:
"""
@@ -562,23 +561,21 @@ class Project(ToStringMixin):
source_file_path=relative_file_path,
)
def create_language_server_manager(
self,
log_level: int = logging.INFO,
ls_timeout: float | None = DEFAULT_TOOL_TIMEOUT - 5,
trace_lsp_communication: bool = False,
ls_specific_settings: dict[Language, Any] | None = None,
) -> LanguageServerManager:
def create_language_server_manager(self) -> LanguageServerManager:
"""
Creates the language server manager for the project, starting one language server per configured programming language.
:param log_level: the log level for the language server
:param ls_timeout: the timeout for the language server
:param trace_lsp_communication: whether to trace LSP communication
:param ls_specific_settings: optional LS specific configuration of the language server,
see docstrings in the inits of subclasses of SolidLanguageServer to see what values may be passed.
:return: the language server manager, which is also stored in the project instance
"""
# determine timeout to use for LS calls
tool_timeout = self._serena_config.tool_timeout
if tool_timeout is None or tool_timeout < 0:
ls_timeout = None
else:
if tool_timeout < 10:
raise ValueError(f"Tool timeout must be at least 10 seconds, but is {tool_timeout} seconds")
ls_timeout = tool_timeout - 5 # the LS timeout is for a single call, it should be smaller than the tool timeout
# if there is an existing instance, stop its language servers first
if self.language_server_manager is not None:
log.info("Stopping existing language server manager ...")
@@ -588,11 +585,12 @@ 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,
ls_specific_settings=ls_specific_settings,
trace_lsp_communication=trace_lsp_communication,
ls_specific_settings=self._serena_config.ls_specific_settings,
trace_lsp_communication=self._serena_config.trace_lsp_communication,
)
self.language_server_manager = LanguageServerManager.from_languages(self.project_config.languages, factory)
return self.language_server_manager
+157
View File
@@ -0,0 +1,157 @@
import json
import logging
from typing import TYPE_CHECKING
import requests as requests_lib
from flask import Flask, request
from pydantic import BaseModel
from sensai.util.logging import LogTime
from serena.config.serena_config import LanguageBackend, SerenaConfig
from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient
if TYPE_CHECKING:
from serena.project import Project
log = logging.getLogger(__name__)
# disable Werkzeug's logging to avoid cluttering the output
logging.getLogger("werkzeug").setLevel(logging.WARNING)
class QueryProjectRequest(BaseModel):
"""
Request model for the /query_project endpoint, matching the interface of
:class:`~serena.tools.query_project_tools.QueryProjectTool`.
"""
project_name: str
tool_name: str
tool_params_json: str
class ProjectServer:
"""
A lightweight Flask server that exposes a SerenaAgent's project querying
capabilities via HTTP, using the LSP language server backend for symbolic retrieval.
Projects are loaded on demand when a query is made for them, and cached in memory for subsequent queries.
The server instantiates a :class:`SerenaAgent` with default options and
provides a ``/query_project`` endpoint whose interface matches
:class:`~serena.tools.query_project_tools.QueryProjectTool`.
"""
PORT = JetBrainsPluginClient.BASE_PORT - 1
def __init__(self) -> None:
from serena.agent import SerenaAgent
serena_config = SerenaConfig.from_config_file()
serena_config.gui_log_window = False
serena_config.web_dashboard = False
serena_config.language_backend = LanguageBackend.LSP
self._agent = SerenaAgent(serena_config=serena_config)
self._loaded_projects_by_name: dict[str, "Project"] = {}
# create the Flask application
self._app = Flask(__name__)
self._setup_routes()
def _setup_routes(self) -> None:
@self._app.route("/heartbeat", methods=["GET"])
def heartbeat() -> dict[str, str]:
return {"status": "alive"}
@self._app.route("/query_project", methods=["POST"])
def query_project() -> str:
query_request = QueryProjectRequest.model_validate(request.get_json())
return self._query_project(query_request)
def _get_project(self, project_name: str) -> "Project":
"""Gets the project with the given name, loading it if necessary."""
if project_name in self._loaded_projects_by_name:
return self._loaded_projects_by_name[project_name]
else:
serena_config = self._agent.serena_config
registered_project = serena_config.get_registered_project(project_name)
if registered_project is None:
raise ValueError(f"Project '{project_name}' is not registered with Serena.")
with LogTime(f"Loading project '{project_name}'"):
project = registered_project.get_project_instance(serena_config)
project.create_language_server_manager()
self._loaded_projects_by_name[project_name] = project
return project
def _query_project(self, req: QueryProjectRequest) -> str:
"""Handle a /query_project request by invoking the agent on the specified project and tool."""
project = self._get_project(req.project_name)
with self._agent.active_project_context(project):
tool = self._agent.get_tool_by_name(req.tool_name)
params = json.loads(req.tool_params_json)
return tool.apply_ex(**params)
def run(self, host: str = "127.0.0.1", port: int = PORT) -> int:
"""Run the server on the given host and port.
:param host: the host address to listen on.
:param port: the port to listen on.
:return: the port number the server is running on.
"""
from flask import cli
# suppress the default Flask startup banner
cli.show_server_banner = lambda *args, **kwargs: None
self._app.run(host=host, port=port, debug=False, use_reloader=False, threaded=True)
return port
class ProjectServerClient:
"""Client for interacting with a running :class:`ProjectServer`.
Upon instantiation, the client verifies that the server is reachable
by sending a heartbeat request. If the server is not running, a
:class:`ConnectionError` is raised.
"""
def __init__(self, host: str = "127.0.0.1", port: int = ProjectServer.PORT, timeout: int = 300) -> None:
"""
:param host: the host address of the project server.
:param port: the port of the project server.
:raises ConnectionError: if the project server is not reachable.
"""
self._base_url = f"http://{host}:{port}"
self._timeout = timeout
# verify that the server is running
try:
response = requests_lib.get(f"{self._base_url}/heartbeat", timeout=5)
response.raise_for_status()
except requests_lib.ConnectionError:
raise ConnectionError(f"ProjectServer is not reachable at {self._base_url}. Make sure the server is running.")
except requests_lib.RequestException as e:
raise ConnectionError(f"ProjectServer health check failed: {e}")
def query_project(self, project_name: str, tool_name: str, tool_params_json: str) -> str:
"""
Query a project by executing a Serena tool in its context.
The interface matches :meth:`QueryProjectTool.apply
<serena.tools.query_project_tools.QueryProjectTool.apply>`.
:param project_name: the name of the project to query.
:param tool_name: the name of the tool to execute. The tool must be read-only.
:param tool_params_json: the parameters to pass to the tool, encoded as a JSON string.
:return: the tool's result as a string.
"""
payload = QueryProjectRequest(
project_name=project_name,
tool_name=tool_name,
tool_params_json=tool_params_json,
).model_dump()
response = requests_lib.post(f"{self._base_url}/query_project", json=payload, timeout=self._timeout)
response.raise_for_status()
return response.text
@@ -0,0 +1,8 @@
description: Enables tools that allow inactive projects to be queried
prompt: |
You can use the 'query_project' tool to query Serena projects without activating them.
Use this when a project is related to the active project and you need to query it for information.
excluded_tools: []
included_optional_tools:
- list_queryable_projects
- query_project
@@ -125,5 +125,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
View File
@@ -7,3 +7,4 @@ from .cmd_tools import *
from .config_tools import *
from .workflow_tools import *
from .jetbrains_tools import *
from .query_project_tools import *
+80
View File
@@ -0,0 +1,80 @@
import json
from serena.config.serena_config import LanguageBackend
from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClientManager
from serena.project_server import ProjectServerClient
from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional
class ListQueryableProjectsTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveProject):
"""
Tool for listing all projects that can be queried by the QueryProjectTool.
"""
def apply(self, symbol_access: bool = True) -> str:
"""
Lists available projects that can be queried with `query_project_tool`.
:param symbol_access: whether to return only projects for which symbol access is available. Default: true
"""
# determine relevant projects
registered_projects = self.agent.serena_config.projects
if symbol_access:
backend = self.agent.get_language_backend()
if backend.is_jetbrains():
# projects with open IDE instances can be queried
matched_clients = JetBrainsPluginClientManager().match_clients(registered_projects)
relevant_projects = [mc.registered_project for mc in matched_clients]
else:
# all projects can be queried via ProjectServer (which instantiates projects dynamically)
relevant_projects = registered_projects
else:
relevant_projects = registered_projects
# return project names, excluding the active project (if any)
project_names = [p.project_name for p in relevant_projects]
active_project = self.agent.get_active_project()
if active_project is not None:
project_names = [n for n in project_names if n != active_project.project_name]
return self._to_json(project_names)
class QueryProjectTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveProject):
"""
Tool for querying external project information (i.e. information from projects other than the current one),
by executing a read-only tool.
"""
def apply(self, project_name: str, tool_name: str, tool_params_json: str) -> str:
"""
Queries a project by executing a read-only Serena tool. The tool will be executed in the context of the project.
Use this to query information from projects other than the activated project.
:param project_name: the name of the project to query
:param tool_name: the name of the tool to execute in the other project. The tool must be read-only.
:param tool_params_json: the parameters to pass to the tool, encoded as a JSON string
"""
tool = self.agent.get_tool_by_name(tool_name)
assert tool.is_active(), f"Tool {tool_name} is not active."
assert tool.is_readonly(), f"Tool {tool_name} is not read-only and cannot be executed in another project."
if self._is_project_server_required(tool):
client = ProjectServerClient()
return client.query_project(project_name, tool_name, tool_params_json)
else:
registered_project = self.agent.serena_config.get_registered_project(project_name)
assert registered_project is not None, f"Project {project_name} is not registered and cannot be queried."
project = registered_project.get_project_instance(self.agent.serena_config)
with tool.agent.active_project_context(project):
return tool.apply(**json.loads(tool_params_json)) # type: ignore
def _is_project_server_required(self, tool: Tool) -> bool:
match self.agent.get_language_backend():
case LanguageBackend.JETBRAINS:
return False
case LanguageBackend.LSP:
# Note: As long as only read-only tools are considered, only symbolic tools require the project server.
# But if we were to allow non-read-only tools, then tools using a CodeEditor also indirectly require language servers.
assert tool.is_readonly()
return tool.is_symbolic()
case _:
raise NotImplementedError
+18 -7
View File
@@ -12,6 +12,7 @@ from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metada
from sensai.util import logging
from sensai.util.string import dict_string
from serena.config.serena_config import LanguageBackend
from serena.project import MemoriesManager, Project
from serena.prompt_factory import PromptFactory
from serena.util.class_decorators import singleton
@@ -36,7 +37,7 @@ class Component(ABC):
"""
:return: the root directory of the active project, raises a ValueError if no active project configuration is set
"""
return self.agent.get_project_root()
return self.project.project_root
@property
def prompt_factory(self) -> PromptFactory:
@@ -49,8 +50,7 @@ class Component(ABC):
def create_language_server_symbol_retriever(self) -> "LanguageServerSymbolRetriever":
from serena.symbol import LanguageServerSymbolRetriever
if not self.agent.is_using_language_server():
raise Exception("Cannot create LanguageServerSymbolRetriever; agent is not in language server mode.")
assert self.agent.get_language_backend().is_lsp(), "Language server symbol retriever can only be created for LSP language backend"
language_server_manager = self.agent.get_language_server_manager_or_raise()
return LanguageServerSymbolRetriever(language_server_manager, agent=self.agent)
@@ -61,10 +61,13 @@ class Component(ABC):
def create_code_editor(self) -> "CodeEditor":
from ..code_editor import JetBrainsCodeEditor, LanguageServerCodeEditor
if self.agent.is_using_language_server():
return LanguageServerCodeEditor(self.create_language_server_symbol_retriever(), agent=self.agent)
else:
return JetBrainsCodeEditor(project=self.project, agent=self.agent)
match self.agent.get_language_backend():
case LanguageBackend.LSP:
return LanguageServerCodeEditor(self.create_language_server_symbol_retriever(), project_config=self.project.project_config)
case LanguageBackend.JETBRAINS:
return JetBrainsCodeEditor(project=self.project)
case _:
raise ValueError
class ToolMarker:
@@ -234,6 +237,12 @@ class Tool(Component):
def is_active(self) -> bool:
return self.agent.tool_is_active(self.get_name())
def is_readonly(self) -> bool:
return not self.can_edit()
def is_symbolic(self) -> bool:
return issubclass(self.__class__, ToolMarkerSymbolicRead) or issubclass(self.__class__, ToolMarkerSymbolicEdit)
def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, mcp_ctx: Context | None = None, **kwargs) -> str: # type: ignore
"""
Applies the tool with logging and exception handling, using the given keyword arguments
@@ -393,6 +402,8 @@ class ToolRegistry:
self._tool_dict[name] = RegisteredTool(tool_class=cls, is_optional=is_optional, tool_name=name)
def get_tool_class_by_name(self, tool_name: str) -> type[Tool]:
if tool_name not in self._tool_dict:
raise ValueError(f"Tool named '{tool_name}' not found.")
return self._tool_dict[tool_name].tool_class
def get_all_tool_classes(self) -> list[type[Tool]]:
+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)
"""
+8 -3
View File
@@ -10,7 +10,7 @@ from typing import Any
import pytest
from sensai.util.logging import configure
from serena.config.serena_config import SerenaPaths
from serena.config.serena_config import SerenaConfig, SerenaPaths
from serena.constants import SERENA_MANAGED_DIR_NAME
from serena.project import Project
from serena.util.file_system import GitignoreParser
@@ -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,9 +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))
return Project.load(repo_path, serena_config=None)
return Project.load(repo_path, serena_config=create_default_serena_config())
@pytest.fixture(scope="session")
@@ -20,9 +20,7 @@ def _create_test_project(
ignored_paths=project_ignored_paths or [],
ignore_all_files_in_gitignore=False,
)
serena_config: SerenaConfig | None = None
if global_ignored_paths:
serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=global_ignored_paths)
serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=global_ignored_paths)
return Project(
project_root=str(project_root),
project_config=config,
@@ -84,21 +82,6 @@ class TestGlobalIgnoredPaths:
# Non-ignored files still accessible
assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js"))
def test_default_global_ignored_paths_backward_compatible(self) -> None:
"""Project created without global_ignored_paths parameter works as before."""
config = ProjectConfig(
project_name="test_project",
languages=[Language.PYTHON],
ignored_paths=["build"],
ignore_all_files_in_gitignore=False,
)
project = Project(
project_root=str(self.project_path),
project_config=config,
)
assert project.is_ignored_path(str(self.project_path / "build" / "output.js"))
assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js"))
def test_duplicate_patterns_across_global_and_project(self) -> None:
"""Duplicate patterns across global and project do not cause errors."""
project = _create_test_project(
@@ -161,7 +144,8 @@ class TestRegisteredProjectGlobalIgnoredPaths:
project_root=str(self.project_path),
project_config=config,
)
project = registered.get_project_instance(serena_config=None)
serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=[])
project = registered.get_project_instance(serena_config=serena_config)
assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js"))
def test_from_project_root_passes_global_ignored_paths(self) -> None:
@@ -175,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"))
+225 -21
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]
@@ -191,6 +201,12 @@ def _make_config_with_project(
global_backend: LanguageBackend = LanguageBackend.LSP,
) -> tuple[SerenaConfig, str]:
"""Create a SerenaConfig with a single registered project and return (config, project_name)."""
config = SerenaConfig(
gui_log_window=False,
web_dashboard=False,
log_level=logging.ERROR,
language_backend=global_backend,
)
project = Project(
project_root=str(Path(__file__).parent.parent / "resources" / "repos" / "python" / "test_repo"),
project_config=ProjectConfig(
@@ -198,12 +214,7 @@ def _make_config_with_project(
languages=[Language.PYTHON],
language_backend=language_backend,
),
)
config = SerenaConfig(
gui_log_window=False,
web_dashboard=False,
log_level=logging.ERROR,
language_backend=global_backend,
serena_config=config,
)
config.projects = [RegisteredProject.from_project_instance(project)]
return config, project_name
@@ -217,8 +228,7 @@ class TestEffectiveLanguageBackend:
config, name = _make_config_with_project("test_proj", language_backend=None, global_backend=LanguageBackend.LSP)
agent = SerenaAgent(project=name, serena_config=config)
try:
assert agent.get_language_backend() == LanguageBackend.LSP
assert agent.is_using_language_server() is True
assert agent.get_language_backend().is_lsp()
finally:
agent.shutdown(timeout=5)
@@ -229,8 +239,7 @@ class TestEffectiveLanguageBackend:
)
agent = SerenaAgent(project=name, serena_config=config)
try:
assert agent.get_language_backend() == LanguageBackend.JETBRAINS
assert agent.is_using_language_server() is False
assert agent.get_language_backend().is_jetbrains()
finally:
agent.shutdown(timeout=5)
@@ -261,6 +270,7 @@ class TestEffectiveLanguageBackend:
languages=[Language.PYTHON],
language_backend=LanguageBackend.JETBRAINS,
),
serena_config=config,
)
config.projects.append(RegisteredProject.from_project_instance(jb_project))
@@ -283,6 +293,7 @@ class TestEffectiveLanguageBackend:
languages=[Language.PYTHON],
language_backend=LanguageBackend.LSP,
),
serena_config=config,
)
config.projects.append(RegisteredProject.from_project_instance(lsp_project2))
@@ -305,6 +316,7 @@ class TestEffectiveLanguageBackend:
languages=[Language.PYTHON],
language_backend=None,
),
serena_config=config,
)
config.projects.append(RegisteredProject.from_project_instance(proj2))
@@ -314,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"]
+4 -3
View File
@@ -21,7 +21,8 @@ from test.solidlsp import clojure as clj
@pytest.fixture
def serena_config():
"""Create an in-memory configuration for tests with test repositories pre-registered."""
config = SerenaConfig(gui_log_window=False, web_dashboard=False, log_level=logging.ERROR)
# Create test projects for all supported languages
test_projects = []
for language in [
@@ -47,16 +48,16 @@ def serena_config():
project_name=project_name,
languages=[language],
ignored_paths=[],
excluded_tools=set(),
excluded_tools=[],
read_only=False,
ignore_all_files_in_gitignore=True,
initial_prompt="",
encoding="utf-8",
),
serena_config=config,
)
test_projects.append(RegisteredProject.from_project_instance(project))
config = SerenaConfig(gui_log_window=False, web_dashboard=False, log_level=logging.ERROR)
config.projects = test_projects
return config
+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},
)