mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-14 06:17:58 +00:00
Allow activation of projects without registration
This commit is contained in:
1 parent
ddbab886cc
commit
13c71bb216
2 files changed
+115
-46
No files matched your search
+111
-42
@@ -86,20 +86,25 @@ class ProjectConfig(ToStringMixin):
|
||||
SERENA_DEFAULT_PROJECT_FILE = "project.yml"
|
||||
|
||||
@classmethod
|
||||
def autogenerate(cls, project_root: str | Path) -> Self:
|
||||
def autogenerate(cls, project_root: str | Path, save_to_disk: bool = True) -> Self:
|
||||
"""
|
||||
Autogenerate a project configuration for a given project root.
|
||||
"""
|
||||
project_root = os.path.abspath(project_root)
|
||||
if not os.path.exists(project_root):
|
||||
raise FileNotFoundError(f"Project root not found: {project_root}")
|
||||
project_name = Path(project_root).name
|
||||
language_composition = determine_programming_language_composition(project_root)
|
||||
# find the language with the highest percentage
|
||||
dominant_language = max(language_composition.keys(), key=lambda lang: language_composition[lang])
|
||||
return cls(
|
||||
result = cls(
|
||||
project_name=project_name,
|
||||
language=Language(dominant_language),
|
||||
project_root=project_root,
|
||||
)
|
||||
if save_to_disk:
|
||||
result.save()
|
||||
return result
|
||||
|
||||
def save(self) -> None:
|
||||
"""
|
||||
@@ -183,9 +188,9 @@ class ProjectConfig(ToStringMixin):
|
||||
raise ValueError(f"Error loading project configuration from {yml_path}: {e}") from e
|
||||
|
||||
@classmethod
|
||||
def from_path(cls, path: Path | str) -> Self:
|
||||
def load(cls, path: Path | str) -> Self:
|
||||
"""
|
||||
Create a ProjectConfig instance from a path. Can either be a path to a yaml file or a directory
|
||||
Load a ProjectConfig instance. Input can either be a path to a yaml file or a directory
|
||||
containing `.serena/project.yml`.
|
||||
"""
|
||||
path = Path(path)
|
||||
@@ -217,6 +222,9 @@ class SerenaConfig:
|
||||
|
||||
CONFIG_FILE = "serena_config.yml"
|
||||
|
||||
def get_project_paths(self) -> list[str]:
|
||||
return [project_config.project_root for project_config in self.projects.values()]
|
||||
|
||||
@classmethod
|
||||
def get_config_file_path(cls) -> str:
|
||||
return os.path.join(serena_root_path(), cls.CONFIG_FILE)
|
||||
@@ -258,7 +266,7 @@ class SerenaConfig:
|
||||
raise SerenaConfigError(f"Invalid project configuration: expected a string or a dict, got {project_entry}.")
|
||||
project_path = Path(project_path).resolve()
|
||||
log.info(f"Loading project configuration from {project_path}")
|
||||
project_config = ProjectConfig.from_path(project_path)
|
||||
project_config = ProjectConfig.load(project_path)
|
||||
projects[project_name] = project_config
|
||||
|
||||
instance.projects = projects
|
||||
@@ -287,6 +295,12 @@ class SerenaConfig:
|
||||
) -> tuple[str, ProjectConfig, bool]:
|
||||
"""
|
||||
Add a project to the Serena configuration from a given path.
|
||||
:param path: the path to the project to add
|
||||
:param project_name: the name of the project to add; if None, the name of the project will be the name of the directory
|
||||
containing the project
|
||||
:param update_config_file: whether to update the Serena configuration file on disk
|
||||
:return: the name of the project that was added, the project configuration, and a boolean indicating whether a new
|
||||
configuration was generated (True) or the existing one was used (False)
|
||||
"""
|
||||
path = Path(path).resolve()
|
||||
if not path.exists():
|
||||
@@ -307,7 +321,7 @@ class SerenaConfig:
|
||||
)
|
||||
|
||||
try:
|
||||
project_config = ProjectConfig.from_path(path)
|
||||
project_config = ProjectConfig.load(path)
|
||||
new_config_was_generated = False
|
||||
except FileNotFoundError:
|
||||
project_config = ProjectConfig.autogenerate(path)
|
||||
@@ -415,7 +429,7 @@ class SerenaAgent:
|
||||
self._project_activation_callback = project_activation_callback
|
||||
|
||||
# project-specific instances, which will be initialized upon project activation
|
||||
self.project_config: ProjectConfig | None = None
|
||||
self._active_project_config: ProjectConfig | None = None
|
||||
self.language_server: SyncLanguageServer | None = None
|
||||
self.symbol_manager: SymbolManager | None = None
|
||||
self.memories_manager: MemoriesManager | None = None
|
||||
@@ -454,7 +468,7 @@ class SerenaAgent:
|
||||
project_config_to_load: ProjectConfig | None = None
|
||||
if project_config is not None:
|
||||
if isinstance(project_config, (str, Path)):
|
||||
project_config_to_load = ProjectConfig.from_path(project_config)
|
||||
project_config_to_load = ProjectConfig.load(project_config)
|
||||
else:
|
||||
project_config_to_load = project_config
|
||||
else:
|
||||
@@ -469,6 +483,12 @@ class SerenaAgent:
|
||||
if not self.serena_config.enable_project_activation:
|
||||
raise ValueError("Tool-based project activation is disabled in the configuration but no project file was provided.")
|
||||
|
||||
def get_active_project_config(self) -> ProjectConfig | None:
|
||||
"""
|
||||
:return: the active project configuration or None if no project is active
|
||||
"""
|
||||
return self._active_project_config
|
||||
|
||||
def get_exposed_tools(self) -> list["Tool"]:
|
||||
"""
|
||||
:return: the list of tools that are to be exposed/registered in the client
|
||||
@@ -516,9 +536,9 @@ class SerenaAgent:
|
||||
# context
|
||||
excluded_tool_classes.update(self._context.get_excluded_tool_classes())
|
||||
# project config
|
||||
if self.project_config is not None:
|
||||
excluded_tool_classes.update(self.project_config.get_excluded_tool_classes())
|
||||
if self.project_config.read_only:
|
||||
if self._active_project_config is not None:
|
||||
excluded_tool_classes.update(self._active_project_config.get_excluded_tool_classes())
|
||||
if self._active_project_config.read_only:
|
||||
for tool_class in self._all_tools:
|
||||
if tool_class.can_edit():
|
||||
excluded_tool_classes.add(tool_class)
|
||||
@@ -531,7 +551,7 @@ class SerenaAgent:
|
||||
|
||||
def activate_project(self, project_config: ProjectConfig) -> None:
|
||||
log.info(f"Activating {project_config}")
|
||||
self.project_config = project_config
|
||||
self._active_project_config = project_config
|
||||
self._update_active_tools()
|
||||
|
||||
# start the language server
|
||||
@@ -540,12 +560,54 @@ class SerenaAgent:
|
||||
|
||||
# initialize project-specific instances
|
||||
self.symbol_manager = SymbolManager(self.language_server, self)
|
||||
self.memories_manager = MemoriesManager(os.path.join(self.project_config.get_serena_managed_dir(), "memories"))
|
||||
self.memories_manager = MemoriesManager(os.path.join(self._active_project_config.get_serena_managed_dir(), "memories"))
|
||||
self.lines_read = LinesRead()
|
||||
|
||||
if self._project_activation_callback is not None:
|
||||
self._project_activation_callback()
|
||||
|
||||
def activate_project_from_path_or_name(self, project: str, register_project: bool = False) -> None:
|
||||
"""
|
||||
Activate a project from a path or a name.
|
||||
If the project was already registered, it will just be activated. If it was not registered and register_project is True,
|
||||
the project will be registered and activated.
|
||||
Finally, if it was not registered and register_project is False, the project will be
|
||||
activated without being registered. In this case, either an existing `ProjectConfig` will be loaded, or a new one
|
||||
will be autogenerated if the project directory exists but no `project.yml` was found.
|
||||
"""
|
||||
# Find or create the project config
|
||||
# Either retrieve it from the registered projects, load it from an existing yaml in the .serena directory within the project,
|
||||
# or autogenerate a new one if the project directory exists but no yaml is present.
|
||||
project_config = None
|
||||
if project in self.serena_config.project_names:
|
||||
project_config = self.serena_config.get_project_configuration(project)
|
||||
elif os.path.exists(project):
|
||||
# maybe user passed a path instead of a name of a registered project
|
||||
project_path = os.path.abspath(project)
|
||||
for registered_project_config in self.serena_config.projects.values():
|
||||
if registered_project_config.project_root == project_path:
|
||||
project_config = registered_project_config
|
||||
break
|
||||
if project_config is not None:
|
||||
log.info(f"Activating registered project {project}")
|
||||
else:
|
||||
if register_project:
|
||||
project_name, project_config, was_autogenerated = self.serena_config.add_project_from_path(project, update_config_file=True)
|
||||
log.info(f"Registered project {project_name} at path {project_config.project_root}.")
|
||||
if was_autogenerated:
|
||||
log.info(
|
||||
f"Note: A new project configuration with language {project_config.language.value} was autogenerated since no project configuration was found."
|
||||
)
|
||||
else:
|
||||
log.info(f"Activating a non-registered project at path {project}")
|
||||
try:
|
||||
project_config = ProjectConfig.load(project)
|
||||
except FileNotFoundError:
|
||||
log.info(f"No project configuration found at path {project}, autogenerating one.")
|
||||
project_config = ProjectConfig.autogenerate(project)
|
||||
log.info(f"Autogenerated project configuration with language {project_config.language.value}.")
|
||||
self.activate_project(project_config)
|
||||
|
||||
def get_active_tool_classes(self) -> list[type["Tool"]]:
|
||||
"""
|
||||
:return: the list of active tool classes for the current project
|
||||
@@ -573,8 +635,8 @@ class SerenaAgent:
|
||||
:return: a string overview of the current configuration, including the active and available configuration options
|
||||
"""
|
||||
result_str = "Current configuration:\n"
|
||||
if self.project_config is not None:
|
||||
result_str += f"Active project: {self.project_config.project_name}\n"
|
||||
if self._active_project_config is not None:
|
||||
result_str += f"Active project: {self._active_project_config.project_name}\n"
|
||||
else:
|
||||
result_str += "No active project\n"
|
||||
result_str += "Available projects:\n" + "\n".join(list(self.serena_config.project_names))
|
||||
@@ -625,18 +687,20 @@ class SerenaAgent:
|
||||
self.language_server = None
|
||||
|
||||
# instantiate and start the language server
|
||||
assert self.project_config is not None
|
||||
multilspy_config = MultilspyConfig(code_language=self.project_config.language, ignored_paths=self.project_config.ignored_paths)
|
||||
assert self._active_project_config is not None
|
||||
multilspy_config = MultilspyConfig(
|
||||
code_language=self._active_project_config.language, ignored_paths=self._active_project_config.ignored_paths
|
||||
)
|
||||
ls_logger = MultilspyLogger()
|
||||
self.language_server = SyncLanguageServer.create(
|
||||
multilspy_config,
|
||||
ls_logger,
|
||||
self.project_config.project_root,
|
||||
add_gitignore_content_to_config=self.project_config.ignore_all_files_in_gitignore,
|
||||
self._active_project_config.project_root,
|
||||
add_gitignore_content_to_config=self._active_project_config.ignore_all_files_in_gitignore,
|
||||
)
|
||||
self.language_server.start()
|
||||
if not self.language_server.is_running():
|
||||
raise RuntimeError(f"Failed to start the language server for {self.project_config}")
|
||||
raise RuntimeError(f"Failed to start the language server for {self._active_project_config}")
|
||||
|
||||
def get_tool(self, tool_class: type[TTool]) -> TTool:
|
||||
return self._all_tools[tool_class] # type: ignore
|
||||
@@ -704,15 +768,14 @@ class Component(ABC):
|
||||
assert self.agent.language_server is not None
|
||||
return self.agent.language_server
|
||||
|
||||
@property
|
||||
def project_root(self) -> str:
|
||||
assert self.project_config is not None
|
||||
return self.project_config.project_root
|
||||
|
||||
@property
|
||||
def project_config(self) -> ProjectConfig:
|
||||
assert self.agent.project_config is not None
|
||||
return self.agent.project_config
|
||||
def get_project_root(self) -> str:
|
||||
"""
|
||||
:return: the root directory of the active project, raises a ValueError if no active project configuration is set
|
||||
"""
|
||||
project_config = self.agent.get_active_project_config()
|
||||
if project_config is None:
|
||||
raise ValueError("Cannot get project root if no active project configuration is set.")
|
||||
return project_config.project_root
|
||||
|
||||
@property
|
||||
def prompt_factory(self) -> PromptFactory:
|
||||
@@ -837,7 +900,7 @@ class Tool(Component):
|
||||
try:
|
||||
# check whether the tool requires an active project and language server
|
||||
if not isinstance(self, ToolMarkerDoesNotRequireActiveProject):
|
||||
if self.agent.project_config is None:
|
||||
if self.agent._active_project_config is None:
|
||||
return (
|
||||
"Error: No active project. Ask to user to select a project from this list: "
|
||||
+ f"{self.agent.serena_config.project_names}"
|
||||
@@ -928,7 +991,7 @@ class CreateTextFileTool(Tool, ToolMarkerCanEdit):
|
||||
:param content: the (utf-8-encoded) content to write to the file
|
||||
:return: a message indicating success or failure
|
||||
"""
|
||||
absolute_path = os.path.join(self.project_root, relative_path)
|
||||
absolute_path = os.path.join(self.get_project_root(), relative_path)
|
||||
os.makedirs(os.path.dirname(absolute_path), exist_ok=True)
|
||||
with open(absolute_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
@@ -953,12 +1016,12 @@ class ListDirTool(Tool):
|
||||
"""
|
||||
|
||||
def is_ignored_path(abs_path: str) -> bool:
|
||||
rel_path = os.path.relpath(abs_path, self.project_root)
|
||||
rel_path = os.path.relpath(abs_path, self.get_project_root())
|
||||
return self.language_server.is_ignored_path(rel_path, ignore_unsupported_files=False)
|
||||
|
||||
dirs, files = scan_directory(
|
||||
os.path.join(self.project_root, relative_path),
|
||||
relative_to=self.project_root,
|
||||
os.path.join(self.get_project_root(), relative_path),
|
||||
relative_to=self.get_project_root(),
|
||||
recursive=recursive,
|
||||
is_ignored_dir=is_ignored_path,
|
||||
is_ignored_file=is_ignored_path,
|
||||
@@ -1565,7 +1628,7 @@ class SearchForPatternTool(Tool):
|
||||
# we walk through all files in the project starting from the root
|
||||
files_to_search = []
|
||||
ignore_spec = self.language_server.get_ignore_spec()
|
||||
for root, dirs, files in os.walk(self.project_root):
|
||||
for root, dirs, files in os.walk(self.get_project_root()):
|
||||
# Don't go into directories that are ignored by modifying dirs inplace
|
||||
# Explanation for the + "/" part:
|
||||
# pathspec can't handle the matching of directories if they don't end with a slash!
|
||||
@@ -1622,7 +1685,7 @@ class ExecuteShellCommandTool(Tool, ToolMarkerCanEdit):
|
||||
required for the task.
|
||||
:return: a JSON object containing the command's stdout and optionally stderr output
|
||||
"""
|
||||
_cwd = cwd or self.project_root
|
||||
_cwd = cwd or self.get_project_root()
|
||||
result = execute_shell_command(command, cwd=_cwd, capture_stderr=capture_stderr)
|
||||
result = result.json()
|
||||
return self._limit_length(result, max_answer_chars)
|
||||
@@ -1633,15 +1696,21 @@ class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject):
|
||||
Activates a project by name.
|
||||
"""
|
||||
|
||||
def apply(self, project_name: str) -> str:
|
||||
def apply(self, project: str, register_project: bool = False) -> str:
|
||||
"""
|
||||
Activates the project with the given name
|
||||
Activates the project with the given name, registering it if requested by the user.
|
||||
Already registered projects will not be registered again.
|
||||
|
||||
:param project_name: the name of the project to activate
|
||||
:param project: the name of a registered project to activate or a path to a project directory
|
||||
:param register_project: whether to register the project in the Serena configuration (if not already done before).
|
||||
Registered projects will be saved in the `serena_config.yml` file and can be activated later by name.
|
||||
Only use this option if explicitly requested by the user.
|
||||
"""
|
||||
project_config = self.agent.serena_config.get_project_configuration(project_name)
|
||||
self.agent.activate_project(project_config)
|
||||
result_str = f"Activated project {project_name}"
|
||||
self.agent.activate_project_from_path_or_name(project, register_project)
|
||||
project_config = self.agent._active_project_config
|
||||
if project_config is None:
|
||||
raise RuntimeError(f"Failed to activate project {project} without raising an error before, this should not happen.")
|
||||
result_str = f"Activated project {project}, language: {project_config.language.value}"
|
||||
result_str += f"\nAdditional project information:\n {project_config.project_info_prompt}"
|
||||
result_str += (
|
||||
f"\nAvailable memories:\n {json.dumps(list(self.memories_manager.list_memories()))}"
|
||||
|
||||
@@ -43,7 +43,7 @@ class TestSerenaAgent:
|
||||
assert any(
|
||||
symbol_name in s["name"] and expected_kind.lower() in s["kind"].lower() and expected_file in s["location"]["relative_path"]
|
||||
for s in symbols
|
||||
), f"Expected to find {symbol_name} ({expected_kind}) in {expected_file} for {agent.project_config.language.name}"
|
||||
), f"Expected to find {symbol_name} ({expected_kind}) in {expected_file} for {agent.get_active_project_config().language.name}"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"serena_agent,symbol_name,def_file,ref_file",
|
||||
@@ -86,7 +86,7 @@ class TestSerenaAgent:
|
||||
refs = json.loads(result)
|
||||
assert any(
|
||||
ref["location"]["relative_path"] == ref_file for ref in refs
|
||||
), f"Expected to find reference to {symbol_name} in {ref_file} for {agent.project_config.language.name}. refs={refs}"
|
||||
), f"Expected to find reference to {symbol_name} in {ref_file} for {agent._active_project_config.language.name}. refs={refs}"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"serena_agent,name_path,substring_matching,expected_symbol_name,expected_kind,expected_file",
|
||||
@@ -180,7 +180,7 @@ class TestSerenaAgent:
|
||||
and expected_kind.lower() in s["kind"].lower()
|
||||
and expected_file in s["location"]["relative_path"]
|
||||
for s in symbols
|
||||
), f"Expected to find {name_path} ({expected_kind}) in {expected_file} for {agent.project_config.language.name}. Symbols: {symbols}"
|
||||
), f"Expected to find {name_path} ({expected_kind}) in {expected_file} for {agent._active_project_config.language.name}. Symbols: {symbols}"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"serena_agent,name_path",
|
||||
@@ -215,4 +215,4 @@ class TestSerenaAgent:
|
||||
symbols = json.loads(result)
|
||||
assert (
|
||||
not symbols
|
||||
), f"Expected to find no symbols for {name_path} for {agent.project_config.language.name}. Symbols found: {symbols}"
|
||||
), f"Expected to find no symbols for {name_path} for {agent._active_project_config.language.name}. Symbols found: {symbols}"
|
||||
Reference in new issue
Block a user