mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-14 04:16:34 +00:00
Merge pull request #1140 from cek/feature/configurable-line-ending
feat: Add configurable line_ending setting for file writes
This commit is contained in:
13 files changed
+206
-172
No files matched your search
@@ -146,3 +146,7 @@ language_backend:
|
||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
read_only_memory_patterns: []
|
||||
|
||||
# line ending convention for file writes: unset (use global setting, "lf", "crlf", or "native" (platform default)
|
||||
# If not undefined, overrides the global setting in serena_config.yml
|
||||
line_ending:
|
||||
+5
-20
@@ -13,7 +13,7 @@ from typing import TYPE_CHECKING, Optional, TypeVar
|
||||
|
||||
from sensai.util import logging
|
||||
from sensai.util.logging import LogTime
|
||||
from sensai.util.string import TextBuilder, dict_string
|
||||
from sensai.util.string import dict_string
|
||||
|
||||
from interprompt.jinja_template import JinjaTemplate
|
||||
from serena import serena_version
|
||||
@@ -29,7 +29,7 @@ from serena.config.serena_config import (
|
||||
ToolInclusionDefinition,
|
||||
)
|
||||
from serena.dashboard import SerenaDashboardAPI
|
||||
from serena.ls_manager import LanguageServerManager, LanguageServerManagerInitialisationError
|
||||
from serena.ls_manager import LanguageServerManager
|
||||
from serena.project import MemoriesManager, Project
|
||||
from serena.prompt_factory import SerenaPromptFactory
|
||||
from serena.task_executor import TaskExecutor
|
||||
@@ -255,7 +255,6 @@ class SerenaAgent:
|
||||
|
||||
# project-specific instances, which will be initialized upon project activation
|
||||
self._active_project: Project | None = None
|
||||
self._lsp_init_error: LanguageServerManagerInitialisationError | None = None
|
||||
|
||||
# determine registered project to be activated (if any)
|
||||
registered_project_to_activate: RegisteredProject | None = (
|
||||
@@ -475,17 +474,8 @@ class SerenaAgent:
|
||||
return None
|
||||
|
||||
def get_language_server_manager_or_raise(self) -> LanguageServerManager:
|
||||
language_server_manager = self.get_language_server_manager()
|
||||
if language_server_manager is None:
|
||||
msg = TextBuilder("The language server manager is not initialized, indicating a problem during project initialisation.")
|
||||
if self._lsp_init_error is not None:
|
||||
msg.with_text(str(self._lsp_init_error))
|
||||
msg.with_text("For details, please check the logs. " + self.get_log_inspection_instructions())
|
||||
msg.with_text(
|
||||
"IMPORTANT: Stop, do not attempt workarounds. Inform the user and wait for further instructions before you continue!"
|
||||
)
|
||||
raise Exception(msg.build())
|
||||
return language_server_manager
|
||||
active_project = self.get_active_project_or_raise()
|
||||
return active_project.get_language_server_manager_or_raise()
|
||||
|
||||
def get_log_inspection_instructions(self) -> str:
|
||||
if self.serena_config.web_dashboard:
|
||||
@@ -820,12 +810,7 @@ class SerenaAgent:
|
||||
"""
|
||||
Starts/resets the language server manager for the current project
|
||||
"""
|
||||
try:
|
||||
self._lsp_init_error = None
|
||||
self.get_active_project_or_raise().create_language_server_manager()
|
||||
except LanguageServerManagerInitialisationError as e:
|
||||
self._lsp_init_error = e
|
||||
raise
|
||||
self.get_active_project_or_raise().create_language_server_manager()
|
||||
|
||||
def add_language(self, language: Language) -> None:
|
||||
"""
|
||||
|
||||
@@ -6,14 +6,12 @@ from collections.abc import Iterable, Iterator, Reversible
|
||||
from contextlib import contextmanager
|
||||
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
|
||||
from solidlsp.ls import LSPFileBuffer
|
||||
from solidlsp.ls_utils import PathUtils, TextUtils
|
||||
|
||||
from .constants import DEFAULT_SOURCE_FILE_ENCODING
|
||||
from .project import Project
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -21,11 +19,10 @@ TSymbol = TypeVar("TSymbol", bound=Symbol)
|
||||
|
||||
|
||||
class CodeEditor(Generic[TSymbol], ABC):
|
||||
def __init__(self, project_root: str, project_config: ProjectConfig | None = None) -> None:
|
||||
self.project_root = project_root
|
||||
|
||||
# set encoding based on project configuration, if available
|
||||
self.encoding = project_config.encoding if project_config is not None else DEFAULT_SOURCE_FILE_ENCODING
|
||||
def __init__(self, project: Project) -> None:
|
||||
self.project_root = project.project_root
|
||||
self.encoding = project.project_config.encoding
|
||||
self.newline = project.line_ending.newline_str
|
||||
|
||||
class EditedFile(ABC):
|
||||
def __init__(self, relative_path: str) -> None:
|
||||
@@ -73,7 +70,7 @@ class CodeEditor(Generic[TSymbol], ABC):
|
||||
def _save_edited_file(self, edited_file: "CodeEditor.EditedFile") -> None:
|
||||
abs_path = os.path.join(self.project_root, edited_file.relative_path)
|
||||
new_contents = edited_file.get_contents()
|
||||
with open(abs_path, "w", encoding=self.encoding) as f:
|
||||
with open(abs_path, "w", encoding=self.encoding, newline=self.newline) as f:
|
||||
f.write(new_contents)
|
||||
|
||||
@abstractmethod
|
||||
@@ -235,8 +232,8 @@ class CodeEditor(Generic[TSymbol], ABC):
|
||||
|
||||
|
||||
class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]):
|
||||
def __init__(self, symbol_retriever: LanguageServerSymbolRetriever, project_config: ProjectConfig | None = None):
|
||||
super().__init__(project_root=symbol_retriever.get_root_path(), project_config=project_config)
|
||||
def __init__(self, symbol_retriever: LanguageServerSymbolRetriever):
|
||||
super().__init__(project=symbol_retriever.project)
|
||||
self._symbol_retriever = symbol_retriever
|
||||
|
||||
def _get_language_server(self, relative_path: str) -> SolidLanguageServer:
|
||||
@@ -372,7 +369,7 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]):
|
||||
class JetBrainsCodeEditor(CodeEditor[JetBrainsSymbol]):
|
||||
def __init__(self, project: Project) -> None:
|
||||
self._project = project
|
||||
super().__init__(project_root=project.project_root, project_config=project.project_config)
|
||||
super().__init__(project)
|
||||
|
||||
class EditedFile(CodeEditor.EditedFile):
|
||||
def __init__(self, relative_path: str, project: Project):
|
||||
|
||||
@@ -182,6 +182,35 @@ class LanguageBackend(Enum):
|
||||
return self == LanguageBackend.JETBRAINS
|
||||
|
||||
|
||||
class LineEnding(Enum):
|
||||
"""Line ending convention for file writes."""
|
||||
|
||||
LF = "lf"
|
||||
CRLF = "crlf"
|
||||
NATIVE = "native"
|
||||
|
||||
@property
|
||||
def newline_str(self) -> str | None:
|
||||
"""The newline parameter value for :func:`open` and :meth:`Path.write_text`.
|
||||
|
||||
Returns ``None`` for native mode (platform default).
|
||||
"""
|
||||
if self is LineEnding.LF:
|
||||
return "\n"
|
||||
elif self is LineEnding.CRLF:
|
||||
return "\r\n"
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def from_str(cls, value: str) -> "LineEnding":
|
||||
"""Parse a string value into a :class:`LineEnding`."""
|
||||
try:
|
||||
return cls(value.lower())
|
||||
except ValueError as e:
|
||||
valid = [le.value for le in cls]
|
||||
raise ValueError(f"Invalid line_ending: {value!r}. Valid values are: {valid}") from e
|
||||
|
||||
|
||||
@dataclass
|
||||
class SharedConfig(ModeSelectionDefinition, ToolInclusionDefinition, ToStringMixin):
|
||||
"""Shared between SerenaConfig and ProjectConfig, the latter used to override values in the form
|
||||
@@ -191,6 +220,7 @@ class SharedConfig(ModeSelectionDefinition, ToolInclusionDefinition, ToStringMix
|
||||
|
||||
symbol_info_budget: float | None = None
|
||||
language_backend: LanguageBackend | None = None
|
||||
line_ending: LineEnding | None = None
|
||||
read_only_memory_patterns: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@@ -388,6 +418,9 @@ class ProjectConfig(SharedConfig):
|
||||
language_backend_value = data.get("language_backend")
|
||||
language_backend = LanguageBackend.from_str(language_backend_value) if language_backend_value else None
|
||||
|
||||
line_ending_value = data.get("line_ending")
|
||||
line_ending = LineEnding.from_str(line_ending_value) if line_ending_value else None
|
||||
|
||||
return cls(
|
||||
project_name=data["project_name"],
|
||||
languages=languages,
|
||||
@@ -400,6 +433,7 @@ class ProjectConfig(SharedConfig):
|
||||
ignore_all_files_in_gitignore=data["ignore_all_files_in_gitignore"],
|
||||
initial_prompt=data["initial_prompt"],
|
||||
encoding=data["encoding"],
|
||||
line_ending=line_ending,
|
||||
language_backend=language_backend,
|
||||
base_modes=data["base_modes"],
|
||||
default_modes=data["default_modes"],
|
||||
@@ -413,6 +447,7 @@ class ProjectConfig(SharedConfig):
|
||||
d = dataclasses.asdict(self)
|
||||
d["languages"] = [lang.value for lang in self.languages]
|
||||
d["language_backend"] = self.language_backend.value if self.language_backend is not None else None
|
||||
d["line_ending"] = self.line_ending.value if self.line_ending is not None else None
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
@@ -556,11 +591,6 @@ class SerenaConfig(SharedConfig):
|
||||
jetbrains_plugin_server_address: str = "127.0.0.1"
|
||||
tool_timeout: float = DEFAULT_TOOL_TIMEOUT
|
||||
|
||||
language_backend: LanguageBackend = LanguageBackend.LSP
|
||||
"""
|
||||
the language backend to use for code understanding features
|
||||
"""
|
||||
|
||||
token_count_estimator: str = RegisteredTokenCountEstimator.CHAR_COUNT.name
|
||||
"""Only relevant if `record_tool_usage` is True; the name of the token count estimator to use for tool usage statistics.
|
||||
See the `RegisteredTokenCountEstimator` enum for available options.
|
||||
@@ -593,7 +623,12 @@ class SerenaConfig(SharedConfig):
|
||||
"""
|
||||
|
||||
# settings with overridden defaults
|
||||
language_backend: LanguageBackend = LanguageBackend.LSP
|
||||
"""
|
||||
the language backend to use for code understanding features
|
||||
"""
|
||||
default_modes: Sequence[str] | None = ("interactive", "editing")
|
||||
line_ending: LineEnding = LineEnding.NATIVE
|
||||
symbol_info_budget: float = 10.0
|
||||
"""
|
||||
Time budget (seconds) for requests when tools request include_info (currently
|
||||
@@ -614,7 +649,7 @@ class SerenaConfig(SharedConfig):
|
||||
# *** static members ***
|
||||
|
||||
CONFIG_FILE = "serena_config.yml"
|
||||
CONFIG_FIELDS_WITH_TYPE_CONVERSION = {"projects", "language_backend"}
|
||||
CONFIG_FIELDS_WITH_TYPE_CONVERSION = {"projects", "language_backend", "line_ending"}
|
||||
|
||||
# *** methods ***
|
||||
@classmethod
|
||||
@@ -746,6 +781,14 @@ class SerenaConfig(SharedConfig):
|
||||
del loaded_commented_yaml["jetbrains"]
|
||||
instance.language_backend = language_backend
|
||||
|
||||
# determine line ending
|
||||
line_ending_value = loaded_commented_yaml.get("line_ending")
|
||||
if line_ending_value:
|
||||
instance.line_ending = LineEnding.from_str(line_ending_value)
|
||||
else:
|
||||
num_migrations += 1
|
||||
instance.line_ending = get_dataclass_default(SerenaConfig, "line_ending")
|
||||
|
||||
# migrate deprecated "gui_log_level" field if necessary
|
||||
if "gui_log_level" in loaded_commented_yaml:
|
||||
num_migrations += 1
|
||||
@@ -913,6 +956,9 @@ class SerenaConfig(SharedConfig):
|
||||
# convert language backend to string
|
||||
commented_yaml["language_backend"] = self.language_backend.value
|
||||
|
||||
# convert line ending to string
|
||||
commented_yaml["line_ending"] = self.line_ending.value
|
||||
|
||||
# transfer comments from the template file
|
||||
# NOTE: The template file now uses leading comments, but we previously used trailing comments,
|
||||
# so we apply a conversion, which detects the old style and transforms it.
|
||||
|
||||
+48
-27
@@ -10,7 +10,7 @@ from typing import Any, Literal
|
||||
|
||||
import pathspec
|
||||
from sensai.util.logging import LogTime
|
||||
from sensai.util.string import ToStringMixin
|
||||
from sensai.util.string import TextBuilder, ToStringMixin
|
||||
|
||||
from serena.config.serena_config import (
|
||||
ProjectConfig,
|
||||
@@ -242,14 +242,18 @@ class Project(ToStringMixin):
|
||||
assert serena_config is not None
|
||||
self.project_root = project_root
|
||||
self.project_config = project_config
|
||||
self._serena_config = serena_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)
|
||||
|
||||
read_only_memory_patterns = serena_config.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)
|
||||
|
||||
# resolve line ending (project -> global)
|
||||
self.line_ending = project_config.line_ending or serena_config.line_ending
|
||||
|
||||
self.language_server_manager: LanguageServerManager | None = None
|
||||
self._language_server_manager_init_error: Exception | None = None
|
||||
self._is_newly_created = is_newly_created
|
||||
|
||||
# create .gitignore file in the project's Serena data folder if not yet present
|
||||
@@ -271,7 +275,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
|
||||
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.")
|
||||
@@ -595,32 +599,49 @@ class Project(ToStringMixin):
|
||||
|
||||
: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
|
||||
try:
|
||||
# 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 ...")
|
||||
self.language_server_manager.stop_all()
|
||||
self.language_server_manager = None
|
||||
# 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 ...")
|
||||
self.language_server_manager.stop_all()
|
||||
self.language_server_manager = None
|
||||
|
||||
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=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)
|
||||
log.info(f"Creating language server manager for {self.project_root}")
|
||||
self._language_server_manager_init_error = None
|
||||
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=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
|
||||
except Exception as e:
|
||||
self._language_server_manager_init_error = e
|
||||
raise
|
||||
|
||||
def get_language_server_manager_or_raise(self) -> LanguageServerManager:
|
||||
if self.language_server_manager is None:
|
||||
msg = TextBuilder("The language server manager is not initialized, indicating a problem during project initialisation.")
|
||||
if self._language_server_manager_init_error is not None:
|
||||
msg.with_text(str(self._lsp_init_error))
|
||||
msg.with_text("For details, please check the logs. " + self.get_log_inspection_instructions())
|
||||
msg.with_text(
|
||||
"IMPORTANT: Stop, do not attempt workarounds. Inform the user and wait for further instructions before you continue!"
|
||||
)
|
||||
raise Exception(msg.build())
|
||||
return self.language_server_manager
|
||||
|
||||
def add_language(self, language: Language) -> None:
|
||||
|
||||
@@ -31,6 +31,11 @@ languages: ["python"]
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: "utf-8"
|
||||
|
||||
# line ending convention to use when writing source files.
|
||||
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||
line_ending:
|
||||
|
||||
# The language backend to use for this project.
|
||||
# If not set, the global setting from serena_config.yml is used.
|
||||
# Valid values: LSP, JetBrains
|
||||
@@ -126,4 +131,4 @@ symbol_info_budget:
|
||||
|
||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
read_only_memory_patterns: []
|
||||
read_only_memory_patterns: []
|
||||
@@ -7,6 +7,12 @@
|
||||
# in your IDE).
|
||||
language_backend: LSP
|
||||
|
||||
# line ending convention to use when writing source files.
|
||||
# Possible values: "lf" (Unix), "crlf" (Windows), "native" (platform default).
|
||||
# Note that Serena's own files (e.g. memories and configuration files) always use native line endings.
|
||||
# This setting can be overridden on a per-project basis in project.yml files.
|
||||
line_ending: native
|
||||
|
||||
# whether to open a graphical window with Serena's logs.
|
||||
# This is mainly supported on Windows and (partly) on Linux; not available on macOS.
|
||||
# If you prefer a browser-based tool, use the `web_dashboard` option instead.
|
||||
|
||||
+10
-25
@@ -5,7 +5,7 @@ from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
from dataclasses import asdict, dataclass
|
||||
from time import perf_counter
|
||||
from typing import TYPE_CHECKING, Any, Generic, Literal, NotRequired, Self, TypedDict, TypeVar, Union
|
||||
from typing import Any, Generic, Literal, NotRequired, Self, TypedDict, TypeVar
|
||||
|
||||
from sensai.util.string import ToStringMixin
|
||||
|
||||
@@ -18,9 +18,6 @@ from solidlsp.ls_types import Position, SymbolKind, UnifiedSymbolInformation
|
||||
from .ls_manager import LanguageServerManager
|
||||
from .project import Project
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .agent import SerenaAgent
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
NAME_PATH_SEP = "/"
|
||||
|
||||
@@ -536,19 +533,12 @@ class ReferenceInLanguageServerSymbol(ToStringMixin):
|
||||
|
||||
|
||||
class LanguageServerSymbolRetriever:
|
||||
def __init__(self, ls: SolidLanguageServer | LanguageServerManager, agent: Union["SerenaAgent", None] = None) -> None:
|
||||
def __init__(self, project: Project) -> None:
|
||||
"""
|
||||
:param ls: the language server or language server manager to use for symbol retrieval and editing operations.
|
||||
:param agent: the agent to use (only needed for marking files as modified). You can pass None if you don't
|
||||
need an agent to be aware of file modifications performed by the symbol manager.
|
||||
:param project: the project instance
|
||||
"""
|
||||
if isinstance(ls, SolidLanguageServer):
|
||||
ls_manager = LanguageServerManager({ls.language: ls})
|
||||
else:
|
||||
ls_manager = ls
|
||||
assert isinstance(ls_manager, LanguageServerManager)
|
||||
self._ls_manager: LanguageServerManager = ls_manager
|
||||
self.agent = agent
|
||||
self._ls_manager: LanguageServerManager = project.get_language_server_manager_or_raise()
|
||||
self.project = project
|
||||
|
||||
def _request_info(self, relative_file_path: str, line: int, column: int, file_buffer: LSPFileBuffer | None = None) -> str | None:
|
||||
"""Retrieves information (in a sanitized format) about the symbol at the desired location,
|
||||
@@ -588,16 +578,11 @@ class LanguageServerSymbolRetriever:
|
||||
return None
|
||||
return self._request_info(relative_file_path=symbol.relative_path, line=symbol.line, column=symbol.column) # type: ignore[arg-type]
|
||||
|
||||
def _get_symbol_info_budget(self, default_budget: float = 10) -> float:
|
||||
"""Project -> global -> default"""
|
||||
symbol_info_budget = default_budget
|
||||
if self.agent is not None:
|
||||
symbol_info_budget = self.agent.serena_config.symbol_info_budget
|
||||
active_project = self.agent.get_active_project()
|
||||
if active_project is not None:
|
||||
project_symbol_info_budget = active_project.project_config.symbol_info_budget
|
||||
if project_symbol_info_budget is not None:
|
||||
symbol_info_budget = project_symbol_info_budget
|
||||
def _get_symbol_info_budget(self) -> float:
|
||||
symbol_info_budget = self.project.serena_config.symbol_info_budget
|
||||
project_symbol_info_budget = self.project.project_config.symbol_info_budget
|
||||
if project_symbol_info_budget is not None:
|
||||
symbol_info_budget = project_symbol_info_budget
|
||||
return symbol_info_budget
|
||||
|
||||
def request_info_for_symbol_batch(
|
||||
|
||||
@@ -73,7 +73,7 @@ class CreateTextFileTool(Tool, ToolMarkerCanEdit):
|
||||
), f"Cannot create file outside of the project directory, got {relative_path=}"
|
||||
|
||||
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
abs_path.write_text(content, encoding=self.project.project_config.encoding)
|
||||
abs_path.write_text(content, encoding=self.project.project_config.encoding, newline=self.project.line_ending.newline_str)
|
||||
answer = f"File created: {relative_path}."
|
||||
if will_overwrite_existing:
|
||||
answer += " Overwrote existing file."
|
||||
|
||||
@@ -51,8 +51,7 @@ class Component(ABC):
|
||||
from serena.symbol import LanguageServerSymbolRetriever
|
||||
|
||||
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)
|
||||
return LanguageServerSymbolRetriever(self.project)
|
||||
|
||||
@property
|
||||
def project(self) -> Project:
|
||||
@@ -63,7 +62,7 @@ class Component(ABC):
|
||||
|
||||
match self.agent.get_language_backend():
|
||||
case LanguageBackend.LSP:
|
||||
return LanguageServerCodeEditor(self.create_language_server_symbol_retriever(), project_config=self.project.project_config)
|
||||
return LanguageServerCodeEditor(self.create_language_server_symbol_retriever())
|
||||
case LanguageBackend.JETBRAINS:
|
||||
return JetBrainsCodeEditor(project=self.project)
|
||||
case _:
|
||||
|
||||
+32
-7
@@ -120,8 +120,8 @@ 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))
|
||||
def _create_default_project(language: Language, repo_root_override: str | None = None) -> Project:
|
||||
repo_path = str(get_repo_path(language)) if repo_root_override is None else repo_root_override
|
||||
return Project.load(repo_path, serena_config=create_default_serena_config())
|
||||
|
||||
|
||||
@@ -178,8 +178,18 @@ def language_server(request: LanguageParamRequest):
|
||||
yield ls
|
||||
|
||||
|
||||
@contextmanager
|
||||
def project_context(language: Language, repo_root_override: str | None = None) -> Iterator[Project]:
|
||||
"""Context manager that creates a Project for the specified language and ensures proper cleanup."""
|
||||
project = _create_default_project(language, repo_root_override)
|
||||
try:
|
||||
yield project
|
||||
finally:
|
||||
project.shutdown(timeout=5)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def project(request: LanguageParamRequest):
|
||||
def project(request: LanguageParamRequest, repo_root_override: str | None = None) -> Iterator[Project]:
|
||||
"""Create a Project for the specified language.
|
||||
|
||||
This fixture requires a language parameter via pytest.mark.parametrize:
|
||||
@@ -203,11 +213,26 @@ def project(request: LanguageParamRequest):
|
||||
"""
|
||||
if not hasattr(request, "param"):
|
||||
raise ValueError("Language parameter must be provided via pytest.mark.parametrize")
|
||||
|
||||
language = request.param
|
||||
project = _create_default_project(language)
|
||||
yield project
|
||||
project.shutdown(timeout=5)
|
||||
with project_context(language, repo_root_override) as project:
|
||||
yield project
|
||||
|
||||
|
||||
@contextmanager
|
||||
def project_with_ls_context(language: Language, repo_root_override: str | None = None) -> Iterator[Project]:
|
||||
"""Context manager that creates a Project with an active language server for the specified language."""
|
||||
with project_context(language, repo_root_override) as project:
|
||||
project.create_language_server_manager()
|
||||
yield project
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def project_with_ls(request: LanguageParamRequest) -> Iterator[Project]:
|
||||
if not hasattr(request, "param"):
|
||||
raise ValueError("Language parameter must be provided via pytest.mark.parametrize")
|
||||
language = request.param
|
||||
with project_with_ls_context(language) as project:
|
||||
yield project
|
||||
|
||||
|
||||
is_ci = os.getenv("CI") == "true" or os.getenv("GITHUB_ACTIONS") == "true"
|
||||
|
||||
+29
-67
@@ -3,8 +3,8 @@ from unittest.mock import MagicMock
|
||||
import pytest
|
||||
|
||||
from serena.jetbrains.jetbrains_types import SymbolDTO, SymbolDTOKey
|
||||
from serena.project import Project
|
||||
from serena.symbol import LanguageServerSymbol, LanguageServerSymbolRetriever, NamePathComponent, NamePathMatcher
|
||||
from solidlsp import SolidLanguageServer
|
||||
from solidlsp.ls_config import Language
|
||||
|
||||
|
||||
@@ -235,9 +235,9 @@ class TestSymbolNameMatching:
|
||||
|
||||
@pytest.mark.python
|
||||
class TestLanguageServerSymbolRetriever:
|
||||
@pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
|
||||
def test_request_info(self, language_server: SolidLanguageServer):
|
||||
symbol_retriever = LanguageServerSymbolRetriever(language_server)
|
||||
@pytest.mark.parametrize("project_with_ls", [Language.PYTHON], indirect=True)
|
||||
def test_request_info(self, project_with_ls: Project):
|
||||
symbol_retriever = LanguageServerSymbolRetriever(project_with_ls)
|
||||
create_user_method_symbol = symbol_retriever.find("UserService/create_user", within_relative_path="test_repo/services.py")[0]
|
||||
create_user_method_symbol_info = symbol_retriever.request_info_for_symbol(create_user_method_symbol)
|
||||
assert "Create a new user and store it" in create_user_method_symbol_info
|
||||
@@ -280,15 +280,14 @@ def _make_mock_symbols(count: int, *, relative_path: str = "test_repo/services.p
|
||||
class TestHoverBudget:
|
||||
"""Tests for symbol_info_budget time budget behavior."""
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
|
||||
def test_budget_not_exceeded_all_lookups_performed(self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("project_with_ls", [Language.PYTHON], indirect=True)
|
||||
def test_budget_not_exceeded_all_lookups_performed(self, project_with_ls: Project, monkeypatch: pytest.MonkeyPatch):
|
||||
"""With a large budget, all hover lookups are performed."""
|
||||
# Create symbol retriever with a mock agent that has large budget
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.serena_config.symbol_info_budget = 10.0
|
||||
mock_agent.get_active_project.return_value = None
|
||||
project_with_ls.serena_config.symbol_info_budget = 10.0
|
||||
project_with_ls.project_config.symbol_info_budget = 10.0
|
||||
|
||||
symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=mock_agent)
|
||||
symbol_retriever = LanguageServerSymbolRetriever(project_with_ls)
|
||||
|
||||
# Track _request_info calls
|
||||
call_count = 0
|
||||
@@ -310,15 +309,13 @@ class TestHoverBudget:
|
||||
assert all(info is not None for info in result.values())
|
||||
assert len(result) == 3
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
|
||||
def test_budget_exceeded_partial_info(self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("project_with_ls", [Language.PYTHON], indirect=True)
|
||||
def test_budget_exceeded_partial_info(self, project_with_ls: Project, monkeypatch: pytest.MonkeyPatch):
|
||||
"""With a small budget, hover lookups stop and remaining symbols get None info."""
|
||||
# Create symbol retriever with a mock agent that has small budget (0.1s)
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.serena_config.symbol_info_budget = 0.1
|
||||
mock_agent.get_active_project.return_value = None
|
||||
project_with_ls.serena_config.symbol_info_budget = 0.1
|
||||
project_with_ls.project_config.symbol_info_budget = 0.1
|
||||
|
||||
symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=mock_agent)
|
||||
symbol_retriever = LanguageServerSymbolRetriever(project_with_ls)
|
||||
|
||||
# Track _request_info calls and simulate 0.05s per call
|
||||
call_count = 0
|
||||
@@ -356,15 +353,13 @@ class TestHoverBudget:
|
||||
assert result_list[3] is None
|
||||
assert result_list[4] is None
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
|
||||
def test_budget_zero_means_unlimited(self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("project_with_ls", [Language.PYTHON], indirect=True)
|
||||
def test_budget_zero_means_unlimited(self, project_with_ls: Project, monkeypatch: pytest.MonkeyPatch):
|
||||
"""With budget=0, all hover lookups proceed (no early stopping)."""
|
||||
# Create symbol retriever with budget=0 (unlimited)
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.serena_config.symbol_info_budget = 0.0
|
||||
mock_agent.get_active_project.return_value = None
|
||||
project_with_ls.serena_config.symbol_info_budget = 0.0
|
||||
project_with_ls.project_config.symbol_info_budget = 0.0
|
||||
|
||||
symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=mock_agent)
|
||||
symbol_retriever = LanguageServerSymbolRetriever(project_with_ls)
|
||||
|
||||
# Track _request_info calls
|
||||
call_count = 0
|
||||
@@ -385,18 +380,14 @@ class TestHoverBudget:
|
||||
assert call_count == 5
|
||||
assert all(info is not None for info in result.values())
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
|
||||
def test_project_budget_overrides_global(self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("project_with_ls", [Language.PYTHON], indirect=True)
|
||||
def test_project_budget_overrides_global(self, project_with_ls: Project, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Project-level budget overrides global budget."""
|
||||
# Create symbol retriever with global budget 10.0 but project budget 0.05
|
||||
mock_project = MagicMock()
|
||||
mock_project.project_config.symbol_info_budget = 0.05
|
||||
project_with_ls.project_config.symbol_info_budget = 0.05
|
||||
project_with_ls.serena_config.symbol_info_budget = 10.0
|
||||
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.serena_config.symbol_info_budget = 10.0
|
||||
mock_agent.get_active_project.return_value = mock_project
|
||||
|
||||
symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=mock_agent)
|
||||
symbol_retriever = LanguageServerSymbolRetriever(project_with_ls)
|
||||
|
||||
# Track _request_info calls and simulate time
|
||||
call_count = 0
|
||||
@@ -427,18 +418,14 @@ class TestHoverBudget:
|
||||
# So 2 calls succeed (proving project budget 0.05 overrode global 10.0)
|
||||
assert call_count == 2
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
|
||||
def test_project_null_inherits_global(self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("project_with_ls", [Language.PYTHON], indirect=True)
|
||||
def test_project_null_inherits_global(self, project_with_ls: Project, monkeypatch: pytest.MonkeyPatch):
|
||||
"""When project budget is None, global budget is used."""
|
||||
# Create symbol retriever with project budget=None (inherit global)
|
||||
mock_project = MagicMock()
|
||||
mock_project.project_config.symbol_info_budget = None
|
||||
project_with_ls.project_config.symbol_info_budget = None
|
||||
project_with_ls.serena_config.symbol_info_budget = 10.0
|
||||
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.serena_config.symbol_info_budget = 10.0
|
||||
mock_agent.get_active_project.return_value = mock_project
|
||||
|
||||
symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=mock_agent)
|
||||
symbol_retriever = LanguageServerSymbolRetriever(project_with_ls)
|
||||
|
||||
# Track _request_info calls
|
||||
call_count = 0
|
||||
@@ -458,28 +445,3 @@ class TestHoverBudget:
|
||||
# Global budget is 10s, all 3 should succeed
|
||||
assert call_count == 3
|
||||
assert all(info is not None for info in result.values())
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
|
||||
def test_no_agent_uses_default_budget(self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch):
|
||||
"""When agent is None, default budget of 5s is used."""
|
||||
# Create symbol retriever without agent
|
||||
symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=None)
|
||||
|
||||
# Track _request_info calls
|
||||
call_count = 0
|
||||
|
||||
def counting_request_info(file_path, line, column, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return f"info:{line}:{column}"
|
||||
|
||||
monkeypatch.setattr(symbol_retriever, "_request_info", counting_request_info)
|
||||
|
||||
# Create 3 mock symbols
|
||||
symbols = _make_mock_symbols(3)
|
||||
|
||||
result = symbol_retriever.request_info_for_symbol_batch(symbols)
|
||||
|
||||
# Default budget is 5s, all 3 should succeed
|
||||
assert call_count == 3
|
||||
assert all(info is not None for info in result.values())
|
||||
@@ -24,7 +24,7 @@ from syrupy import SnapshotAssertion
|
||||
from serena.code_editor import CodeEditor, LanguageServerCodeEditor
|
||||
from solidlsp.ls_config import Language
|
||||
from src.serena.symbol import LanguageServerSymbolRetriever
|
||||
from test.conftest import get_repo_path, start_ls_context
|
||||
from test.conftest import get_repo_path, project_with_ls_context
|
||||
|
||||
pytestmark = pytest.mark.snapshot
|
||||
|
||||
@@ -192,7 +192,6 @@ class EditingTest(ABC):
|
||||
"""Context manager for setup/teardown with a temporary directory, providing the symbol manager."""
|
||||
temp_dir = Path(tempfile.mkdtemp())
|
||||
self.repo_path = temp_dir / self.original_repo_path.name
|
||||
language_server = None # Initialize language_server
|
||||
try:
|
||||
print(f"Copying repo from {self.original_repo_path} to {self.repo_path}")
|
||||
shutil.copytree(self.original_repo_path, self.repo_path)
|
||||
@@ -201,8 +200,8 @@ class EditingTest(ABC):
|
||||
if os.name == "nt":
|
||||
time.sleep(0.1)
|
||||
log.info(f"Creating language server for {self.language} {self.rel_path}")
|
||||
with start_ls_context(self.language, str(self.repo_path)) as language_server:
|
||||
yield LanguageServerSymbolRetriever(ls=language_server)
|
||||
with project_with_ls_context(self.language, str(self.repo_path)) as project:
|
||||
yield LanguageServerSymbolRetriever(project)
|
||||
finally:
|
||||
# prevent deadlock on Windows due to lingering file locks
|
||||
if os.name == "nt":
|
||||
|
||||
Reference in new issue
Block a user