From 7b287fa60211d4ad1eb46ef0548238441a971fcd Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 7 Dec 2025 13:39:08 +0100 Subject: [PATCH] Allow Serena home directory to be overridden with SERENA_HOME env var, defining the relevant path variables in SerenaPaths --- src/serena/cli.py | 33 +++++++++++++-------------- src/serena/config/context_mode.py | 16 ++++++------- src/serena/config/serena_config.py | 36 ++++++++++++++++++++++++------ src/serena/constants.py | 8 ------- src/serena/ls_manager.py | 5 +++-- src/serena/prompt_factory.py | 8 ++++--- test/conftest.py | 7 ++++-- 7 files changed, 64 insertions(+), 49 deletions(-) diff --git a/src/serena/cli.py b/src/serena/cli.py index dc47d9b4..e7cca2be 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -21,14 +21,10 @@ from serena.config.serena_config import ProjectConfig, SerenaConfig, SerenaPaths from serena.constants import ( DEFAULT_CONTEXT, DEFAULT_MODES, - PROMPT_TEMPLATES_DIR_IN_USER_HOME, PROMPT_TEMPLATES_DIR_INTERNAL, SERENA_LOG_FORMAT, - SERENA_MANAGED_DIR_IN_HOME, SERENAS_OWN_CONTEXT_YAMLS_DIR, SERENAS_OWN_MODE_YAMLS_DIR, - USER_CONTEXT_YAMLS_DIR, - USER_MODE_YAMLS_DIR, ) from serena.mcp import SerenaMCPFactory, SerenaMCPFactorySingleProcess from serena.project import Project @@ -270,7 +266,7 @@ class ModeCommands(AutoRegisteringGroup): if not (name or from_internal): raise click.UsageError("Provide at least one of --name or --from-internal.") mode_name = name or from_internal - dest = os.path.join(USER_MODE_YAMLS_DIR, f"{mode_name}.yml") + dest = os.path.join(SerenaPaths().user_modes_dir, f"{mode_name}.yml") src = ( os.path.join(SERENAS_OWN_MODE_YAMLS_DIR, f"{from_internal}.yml") if from_internal @@ -289,7 +285,7 @@ class ModeCommands(AutoRegisteringGroup): @click.command("edit", help="Edit a custom mode YAML file.") @click.argument("mode_name") def edit(mode_name: str) -> None: - path = os.path.join(USER_MODE_YAMLS_DIR, f"{mode_name}.yml") + path = os.path.join(SerenaPaths().user_modes_dir, f"{mode_name}.yml") if not os.path.exists(path): if mode_name in SerenaAgentMode.list_registered_mode_names(include_user_modes=False): click.echo( @@ -305,7 +301,7 @@ class ModeCommands(AutoRegisteringGroup): @click.command("delete", help="Delete a custom mode file.") @click.argument("mode_name") def delete(mode_name: str) -> None: - path = os.path.join(USER_MODE_YAMLS_DIR, f"{mode_name}.yml") + path = os.path.join(SerenaPaths().user_modes_dir, f"{mode_name}.yml") if not os.path.exists(path): click.echo(f"Custom mode '{mode_name}' not found.") return @@ -347,7 +343,7 @@ class ContextCommands(AutoRegisteringGroup): if not (name or from_internal): raise click.UsageError("Provide at least one of --name or --from-internal.") ctx_name = name or from_internal - dest = os.path.join(USER_CONTEXT_YAMLS_DIR, f"{ctx_name}.yml") + dest = os.path.join(SerenaPaths().user_contexts_dir, f"{ctx_name}.yml") src = ( os.path.join(SERENAS_OWN_CONTEXT_YAMLS_DIR, f"{from_internal}.yml") if from_internal @@ -366,7 +362,7 @@ class ContextCommands(AutoRegisteringGroup): @click.command("edit", help="Edit a custom context YAML file.") @click.argument("context_name") def edit(context_name: str) -> None: - path = os.path.join(USER_CONTEXT_YAMLS_DIR, f"{context_name}.yml") + path = os.path.join(SerenaPaths().user_contexts_dir, f"{context_name}.yml") if not os.path.exists(path): if context_name in SerenaAgentContext.list_registered_context_names(include_user_contexts=False): click.echo( @@ -382,7 +378,7 @@ class ContextCommands(AutoRegisteringGroup): @click.command("delete", help="Delete a custom context file.") @click.argument("context_name") def delete(context_name: str) -> None: - path = os.path.join(USER_CONTEXT_YAMLS_DIR, f"{context_name}.yml") + path = os.path.join(SerenaPaths().user_contexts_dir, f"{context_name}.yml") if not os.path.exists(path): click.echo(f"Custom context '{context_name}' not found.") return @@ -401,10 +397,9 @@ class SerenaConfigCommands(AutoRegisteringGroup): "edit", help="Edit serena_config.yml in your default editor. Will create a config file from the template if no config is found." ) def edit() -> None: - config_path = os.path.join(SERENA_MANAGED_DIR_IN_HOME, "serena_config.yml") - if not os.path.exists(config_path): - SerenaConfig.generate_config_file(config_path) - _open_in_editor(config_path) + serena_config = SerenaConfig.from_config_file() + assert serena_config.config_file_path is not None + _open_in_editor(serena_config.config_file_path) class ProjectCommands(AutoRegisteringGroup): @@ -811,8 +806,9 @@ class PromptCommands(AutoRegisteringGroup): @staticmethod def _get_user_prompt_yaml_path(prompt_yaml_name: str) -> str: - os.makedirs(PROMPT_TEMPLATES_DIR_IN_USER_HOME, exist_ok=True) - return os.path.join(PROMPT_TEMPLATES_DIR_IN_USER_HOME, prompt_yaml_name) + templates_dir = SerenaPaths().user_prompt_templates_dir + os.makedirs(templates_dir, exist_ok=True) + return os.path.join(templates_dir, prompt_yaml_name) @staticmethod @click.command("list", help="Lists yamls that are used for defining prompts.") @@ -863,9 +859,10 @@ class PromptCommands(AutoRegisteringGroup): @staticmethod @click.command("list-overrides", help="List existing prompt override files") def list_overrides() -> None: - os.makedirs(PROMPT_TEMPLATES_DIR_IN_USER_HOME, exist_ok=True) + user_templates_dir = SerenaPaths().user_prompt_templates_dir + os.makedirs(user_templates_dir, exist_ok=True) serena_prompt_yaml_names = [os.path.basename(f) for f in glob.glob(PROMPT_TEMPLATES_DIR_INTERNAL + "/*.yml")] - override_files = glob.glob(os.path.join(PROMPT_TEMPLATES_DIR_IN_USER_HOME, "*.yml")) + override_files = glob.glob(os.path.join(user_templates_dir, "*.yml")) for file_path in override_files: if os.path.basename(file_path) in serena_prompt_yaml_names: click.echo(file_path) diff --git a/src/serena/config/context_mode.py b/src/serena/config/context_mode.py index dfff0937..f1f34fd9 100644 --- a/src/serena/config/context_mode.py +++ b/src/serena/config/context_mode.py @@ -11,7 +11,7 @@ import yaml from sensai.util import logging from sensai.util.string import ToStringMixin -from serena.config.serena_config import ToolInclusionDefinition +from serena.config.serena_config import SerenaPaths, ToolInclusionDefinition from serena.constants import ( DEFAULT_CONTEXT, DEFAULT_MODES, @@ -19,8 +19,6 @@ from serena.constants import ( SERENA_FILE_ENCODING, SERENAS_OWN_CONTEXT_YAMLS_DIR, SERENAS_OWN_MODE_YAMLS_DIR, - USER_CONTEXT_YAMLS_DIR, - USER_MODE_YAMLS_DIR, ) if TYPE_CHECKING: @@ -65,14 +63,14 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): def get_path(cls, name: str) -> str: """Get the path to the YAML file for a mode.""" fname = f"{name}.yml" - custom_mode_path = os.path.join(USER_MODE_YAMLS_DIR, fname) + custom_mode_path = os.path.join(SerenaPaths().user_modes_dir, fname) if os.path.exists(custom_mode_path): return custom_mode_path own_yaml_path = os.path.join(SERENAS_OWN_MODE_YAMLS_DIR, fname) if not os.path.exists(own_yaml_path): raise FileNotFoundError( - f"Mode {name} not found in {USER_MODE_YAMLS_DIR} or in {SERENAS_OWN_MODE_YAMLS_DIR}." + f"Mode {name} not found in {SerenaPaths().user_modes_dir} or in {SERENAS_OWN_MODE_YAMLS_DIR}." f"Available modes:\n{cls.list_registered_mode_names()}" ) return own_yaml_path @@ -102,7 +100,7 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): @classmethod def list_custom_mode_names(cls) -> list[str]: """Names of all custom modes defined by the user.""" - return [f.stem for f in Path(USER_MODE_YAMLS_DIR).glob("*.yml")] + return [f.stem for f in Path(SerenaPaths().user_modes_dir).glob("*.yml")] @classmethod def load_default_modes(cls) -> list[Self]: @@ -166,14 +164,14 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin): def get_path(cls, name: str) -> str: """Get the path to the YAML file for a context.""" fname = f"{name}.yml" - custom_context_path = os.path.join(USER_CONTEXT_YAMLS_DIR, fname) + custom_context_path = os.path.join(SerenaPaths().user_contexts_dir, fname) if os.path.exists(custom_context_path): return custom_context_path own_yaml_path = os.path.join(SERENAS_OWN_CONTEXT_YAMLS_DIR, fname) if not os.path.exists(own_yaml_path): raise FileNotFoundError( - f"Context {name} not found in {USER_CONTEXT_YAMLS_DIR} or in {SERENAS_OWN_CONTEXT_YAMLS_DIR}." + f"Context {name} not found in {SerenaPaths().user_contexts_dir} or in {SERENAS_OWN_CONTEXT_YAMLS_DIR}." f"Available contexts:\n{cls.list_registered_context_names()}" ) return own_yaml_path @@ -211,7 +209,7 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin): @classmethod def list_custom_context_names(cls) -> list[str]: """Names of all custom contexts defined by the user.""" - return [f.stem for f in Path(USER_CONTEXT_YAMLS_DIR).glob("*.yml")] + return [f.stem for f in Path(SerenaPaths().user_contexts_dir).glob("*.yml")] @classmethod def load_default(cls) -> Self: diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index c5345d48..c3fb2a4a 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -25,7 +25,6 @@ from serena.constants import ( REPO_ROOT, SERENA_CONFIG_TEMPLATE_FILE, SERENA_FILE_ENCODING, - SERENA_MANAGED_DIR_IN_HOME, SERENA_MANAGED_DIR_NAME, ) from serena.util.general import load_yaml, save_yaml @@ -53,9 +52,32 @@ class SerenaPaths: """ def __init__(self) -> None: - self.user_config_dir: str = SERENA_MANAGED_DIR_IN_HOME + home_dir = os.getenv("SERENA_HOME") + if home_dir is None or home_dir.strip() == "": + home_dir = str(Path.home() / SERENA_MANAGED_DIR_NAME) + else: + home_dir = home_dir.strip() + self.serena_user_home_dir: str = home_dir """ - the path to the user's Serena configuration directory, which is typically ~/.serena + the path to the Serena home directory, where the user's configuration/data is stored. + This is ~/.serena by default, but it can be overridden via the SERENA_HOME environment variable. + """ + self.user_prompt_templates_dir: str = os.path.join(self.serena_user_home_dir, "prompt_templates") + """ + directory containing prompt templates defined by the user. + Prompts defined by the user take precedence over Serena's built-in prompt templates. + """ + self.user_contexts_dir: str = os.path.join(self.serena_user_home_dir, "contexts") + """ + directory containing contexts defined by the user. + If a name of a context matches a name of a context in SERENAS_OWN_CONTEXT_YAMLS_DIR, + the user context will override the default context definition. + """ + self.user_modes_dir: str = os.path.join(self.serena_user_home_dir, "modes") + """ + directory containing modes defined by the user. + If a name of a mode matches a name of a mode in SERENAS_OWN_MODES_YAML_DIR, + the user mode will override the default mode definition. """ def get_next_log_file_path(self, prefix: str) -> str: @@ -63,7 +85,7 @@ class SerenaPaths: :param prefix: the filename prefix indicating the type of the log file :return: the full path to the log file to use """ - log_dir = os.path.join(self.user_config_dir, "logs", datetime.now().strftime("%Y-%m-%d")) + log_dir = os.path.join(self.serena_user_home_dir, "logs", datetime.now().strftime("%Y-%m-%d")) os.makedirs(log_dir, exist_ok=True) return os.path.join(log_dir, prefix + "_" + datetime_tag() + ".txt") @@ -377,7 +399,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): return ["config_file_path"] @classmethod - def generate_config_file(cls, config_file_path: str) -> None: + def _generate_config_file(cls, config_file_path: str) -> None: """ Generates a Serena configuration file at the specified path from the template file. @@ -395,7 +417,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): if is_running_in_docker(): return os.path.join(REPO_ROOT, cls.CONFIG_FILE_DOCKER) else: - config_path = os.path.join(SERENA_MANAGED_DIR_IN_HOME, cls.CONFIG_FILE) + config_path = os.path.join(SerenaPaths().serena_user_home_dir, cls.CONFIG_FILE) # if the config file does not exist, check if we can migrate it from the old location if not os.path.exists(config_path): @@ -419,7 +441,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): if not generate_if_missing: raise FileNotFoundError(f"Serena configuration file not found: {config_file_path}") log.info(f"Serena configuration file not found at {config_file_path}, autogenerating...") - cls.generate_config_file(config_file_path) + cls._generate_config_file(config_file_path) # load the configuration log.info(f"Loading Serena configuration from {config_file_path}") diff --git a/src/serena/constants.py b/src/serena/constants.py index e0ed29cf..f5ccd660 100644 --- a/src/serena/constants.py +++ b/src/serena/constants.py @@ -4,22 +4,14 @@ _repo_root_path = Path(__file__).parent.parent.parent.resolve() _serena_pkg_path = Path(__file__).parent.resolve() SERENA_MANAGED_DIR_NAME = ".serena" -_serena_in_home_managed_dir = Path.home() / ".serena" - -SERENA_MANAGED_DIR_IN_HOME = str(_serena_in_home_managed_dir) # TODO: Path-related constants should be moved to SerenaPaths; don't add further constants here. REPO_ROOT = str(_repo_root_path) PROMPT_TEMPLATES_DIR_INTERNAL = str(_serena_pkg_path / "resources" / "config" / "prompt_templates") -PROMPT_TEMPLATES_DIR_IN_USER_HOME = str(_serena_in_home_managed_dir / "prompt_templates") SERENAS_OWN_CONTEXT_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "contexts") """The contexts that are shipped with the Serena package, i.e. the default contexts.""" -USER_CONTEXT_YAMLS_DIR = str(_serena_in_home_managed_dir / "contexts") -"""Contexts defined by the user. If a name of a context matches a name of a context in SERENAS_OWN_CONTEXT_YAMLS_DIR, the user context will override the default one.""" SERENAS_OWN_MODE_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "modes") """The modes that are shipped with the Serena package, i.e. the default modes.""" -USER_MODE_YAMLS_DIR = str(_serena_in_home_managed_dir / "modes") -"""Modes defined by the user. If a name of a mode matches a name of a mode in SERENAS_OWN_MODE_YAMLS_DIR, the user mode will override the default one.""" INTERNAL_MODE_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "internal_modes") """Internal modes, never overridden by user modes.""" SERENA_DASHBOARD_DIR = str(_serena_pkg_path / "resources" / "dashboard") diff --git a/src/serena/ls_manager.py b/src/serena/ls_manager.py index 6ec224b8..8f3681bf 100644 --- a/src/serena/ls_manager.py +++ b/src/serena/ls_manager.py @@ -4,7 +4,8 @@ from collections.abc import Iterator from sensai.util.logging import LogTime -from serena.constants import SERENA_MANAGED_DIR_IN_HOME, SERENA_MANAGED_DIR_NAME +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 @@ -43,7 +44,7 @@ class LanguageServerFactory: self.project_root, timeout=self.ls_timeout, solidlsp_settings=SolidLSPSettings( - solidlsp_dir=SERENA_MANAGED_DIR_IN_HOME, + solidlsp_dir=SerenaPaths().serena_user_home_dir, project_data_relative_path=SERENA_MANAGED_DIR_NAME, ls_specific_settings=self.ls_specific_settings or {}, ), diff --git a/src/serena/prompt_factory.py b/src/serena/prompt_factory.py index 40f51e7a..31be6f49 100644 --- a/src/serena/prompt_factory.py +++ b/src/serena/prompt_factory.py @@ -1,6 +1,7 @@ import os -from serena.constants import PROMPT_TEMPLATES_DIR_IN_USER_HOME, PROMPT_TEMPLATES_DIR_INTERNAL +from serena.config.serena_config import SerenaPaths +from serena.constants import PROMPT_TEMPLATES_DIR_INTERNAL from serena.generated.generated_prompt_factory import PromptFactory @@ -10,5 +11,6 @@ class SerenaPromptFactory(PromptFactory): """ def __init__(self) -> None: - os.makedirs(PROMPT_TEMPLATES_DIR_IN_USER_HOME, exist_ok=True) - super().__init__(prompts_dir=[PROMPT_TEMPLATES_DIR_IN_USER_HOME, PROMPT_TEMPLATES_DIR_INTERNAL]) + user_templates_dir = SerenaPaths().user_prompt_templates_dir + os.makedirs(user_templates_dir, exist_ok=True) + super().__init__(prompts_dir=[user_templates_dir, PROMPT_TEMPLATES_DIR_INTERNAL]) diff --git a/test/conftest.py b/test/conftest.py index c9fc31ce..84aa80f3 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -7,7 +7,8 @@ import pytest from blib2to3.pgen2.parse import contextmanager from sensai.util.logging import configure -from serena.constants import SERENA_MANAGED_DIR_IN_HOME, SERENA_MANAGED_DIR_NAME +from serena.config.serena_config import SerenaPaths +from serena.constants import SERENA_MANAGED_DIR_NAME from serena.project import Project from serena.util.file_system import GitignoreParser from solidlsp.ls import SolidLanguageServer @@ -49,7 +50,9 @@ def _create_ls( return SolidLanguageServer.create( config, repo_path, - solidlsp_settings=SolidLSPSettings(solidlsp_dir=SERENA_MANAGED_DIR_IN_HOME, project_data_relative_path=SERENA_MANAGED_DIR_NAME), + solidlsp_settings=SolidLSPSettings( + solidlsp_dir=SerenaPaths().serena_user_home_dir, project_data_relative_path=SERENA_MANAGED_DIR_NAME + ), )