From e85afc1ea8ce7179dfdb9fe9e6a9e578b5de2d2f Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 30 Oct 2025 01:49:02 +0100 Subject: [PATCH] Apply configuration updates (with defaults and backward compatibility) at the dictionary level, allowing old configurations to be properly updated when resaving (outdated entries removed/new entries added in re-saved version) --- src/serena/config/serena_config.py | 60 ++++++++++++++++++++---------- src/serena/project.py | 4 +- 2 files changed, 43 insertions(+), 21 deletions(-) diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index cbdbf6d4..848d94c8 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -223,21 +223,44 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): def rel_path_to_project_yml(cls) -> str: return os.path.join(SERENA_MANAGED_DIR_NAME, cls.SERENA_DEFAULT_PROJECT_FILE) + @classmethod + def load_commented_map(cls, yml_path: str) -> CommentedMap: + """ + Load the project configuration as a CommentedMap, preserving comments and ensuring + completeness of the configuration by applying default values for missing fields + and backward compatibility adjustments. + + :param yml_path: the path to the project.yml file + :return: a CommentedMap representing a full project configuration + """ + data = load_yaml(yml_path, preserve_comments=True) + + # apply defaults for new fields + data["languages"] = data.get("languages", []) + data["ignored_paths"] = data.get("ignored_paths", []) + data["excluded_tools"] = data.get("excluded_tools", []) + data["included_optional_tools"] = data.get("included_optional_tools", []) + data["read_only"] = data.get("read_only", False) + data["ignore_all_files_in_gitignore"] = data.get("ignore_all_files_in_gitignore", True) + data["initial_prompt"] = data.get("initial_prompt", "") + data["encoding"] = data.get("encoding", DEFAULT_SOURCE_FILE_ENCODING) + + # backward compatibility: handle single "language" field + if len(data["languages"]) == 0 and "language" in data: + data["languages"] = [data["language"]] + if "language" in data: + del data["language"] + + return data + @classmethod def _from_dict(cls, data: dict[str, Any]) -> Self: """ - Create a ProjectConfig instance from a configuration dictionary + Create a ProjectConfig instance from a (full) configuration dictionary """ - project_name = data["project_name"] - language_strings = data.get("languages", []) - - # backwards compatibility lang_name_mapping = {"javascript": "typescript"} - if len(language_strings) == 0 and "language" in data: - language_strings = [data["language"]] - languages: list[Language] = [] - for language_str in language_strings: + for language_str in data["languages"]: try: language_str = language_str.lower() if language_str in lang_name_mapping: @@ -248,15 +271,15 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): raise ValueError(f"Invalid language: {data['language']}.\nValid language_strings are: {[l.value for l in Language]}") from e return cls( - project_name=project_name, + project_name=data["project_name"], languages=languages, - ignored_paths=data.get("ignored_paths", []), - excluded_tools=data.get("excluded_tools", []), - included_optional_tools=data.get("included_optional_tools", []), - read_only=data.get("read_only", False), - ignore_all_files_in_gitignore=data.get("ignore_all_files_in_gitignore", True), - initial_prompt=data.get("initial_prompt", ""), - encoding=data.get("encoding", DEFAULT_SOURCE_FILE_ENCODING), + ignored_paths=data["ignored_paths"], + excluded_tools=data["excluded_tools"], + included_optional_tools=data["included_optional_tools"], + read_only=data["read_only"], + ignore_all_files_in_gitignore=data["ignore_all_files_in_gitignore"], + initial_prompt=data["initial_prompt"], + encoding=data["encoding"], ) def to_yaml_dict(self) -> dict: @@ -279,8 +302,7 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): return cls.autogenerate(project_root) else: raise FileNotFoundError(f"Project configuration file not found: {yaml_path}") - with open(yaml_path, encoding=SERENA_FILE_ENCODING) as f: - yaml_data = yaml.safe_load(f) + yaml_data = cls.load_commented_map(str(yaml_path)) if "project_name" not in yaml_data: yaml_data["project_name"] = project_root.name return cls._from_dict(yaml_data) diff --git a/src/serena/project.py b/src/serena/project.py index 662a87f8..c1227523 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -12,7 +12,7 @@ from serena.constants import SERENA_FILE_ENCODING, SERENA_MANAGED_DIR_NAME from serena.ls_manager import LanguageServerFactory, LanguageServerManager from serena.text_utils import MatchedConsecutiveLines, search_files from serena.util.file_system import GitignoreParser, match_path -from serena.util.general import load_yaml, save_yaml +from serena.util.general import save_yaml from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import FileUtils @@ -116,7 +116,7 @@ class Project(ToStringMixin): """ config_path = os.path.join(self.project_root, self.project_config.rel_path_to_project_yml()) log.info("Saving updated project configuration to %s", config_path) - config_with_comments = load_yaml(config_path, preserve_comments=True) + config_with_comments = ProjectConfig.load_commented_map(config_path) config_with_comments.update(self.project_config.to_yaml_dict()) save_yaml(config_path, config_with_comments, preserve_comments=True)