From 1ec5a731a3c373442d6e4015b35b441f9e8636fc Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 22 Jan 2026 14:29:28 +0100 Subject: [PATCH] cli: Allow "project index" with project name (not just path), refactoring --- src/serena/cli.py | 45 +++++++++++------------- src/serena/config/serena_config.py | 33 +++++++++++++++-- test/serena/test_cli_project_commands.py | 6 ++-- 3 files changed, 54 insertions(+), 30 deletions(-) diff --git a/src/serena/cli.py b/src/serena/cli.py index 345c58c3..fba9ccdc 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -510,16 +510,16 @@ class ProjectCommands(AutoRegisteringGroup): ) @staticmethod - def _create_project(project_path: str, name: str | None, language: tuple[str, ...]) -> ProjectConfig: + def _create_project(project_path: str, name: str | None, language: tuple[str, ...]) -> RegisteredProject: """ Helper method to create a project configuration file. :param project_path: Path to the project directory :param name: Optional project name (defaults to directory name if not specified) :param language: Tuple of language names - :return: The generated ProjectConfig instance :raises FileExistsError: If project.yml already exists :raises ValueError: If an unsupported language is specified + :return: the RegisteredProject instance """ project_root = Path(project_path).resolve() yml_path = ProjectConfig.path_to_project_yml(project_root) @@ -544,13 +544,12 @@ class ProjectCommands(AutoRegisteringGroup): # add to SerenaConfig's list of registered projects serena_config = SerenaConfig.from_config_file() - if serena_config.get_registered_project(str(project_root)) is None: + registered_project = serena_config.get_registered_project(str(project_root)) + if registered_project is None: registered_project = RegisteredProject(str(project_root), generated_conf) - serena_config.projects.append(registered_project) - click.echo(f"Saving project registration to {serena_config}") - serena_config.save() + serena_config.add_registered_project(registered_project) - return generated_conf + return registered_project @staticmethod @click.command("create", help="Create a new Serena project configuration.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}) @@ -569,10 +568,10 @@ class ProjectCommands(AutoRegisteringGroup): @click.option("--timeout", type=float, default=10, help="Timeout for indexing a single file (only used if --index is set).") def create(project_path: str, name: str | None, language: tuple[str, ...], index: bool, log_level: str, timeout: float) -> None: try: - ProjectCommands._create_project(project_path, name, language) + registered_project = ProjectCommands._create_project(project_path, name, language) if index: click.echo("Indexing project...") - ProjectCommands._index_project(project_path, log_level, timeout=timeout) + ProjectCommands._index_project(registered_project, log_level, timeout=timeout) except FileExistsError as e: raise click.ClickException(f"Project already exists: {e}\nUse 'serena project index' to index an existing project.") except ValueError as e: @@ -584,7 +583,7 @@ class ProjectCommands(AutoRegisteringGroup): help="Index a project by saving symbols to the LSP cache. Auto-creates project.yml if it doesn't exist.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, ) - @click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False) + @click.argument("project", type=PROJECT_TYPE, default=os.getcwd(), required=False) @click.option("--name", type=str, default=None, help="Project name (only used if auto-creating project.yml).") @click.option( "--language", @@ -600,32 +599,30 @@ class ProjectCommands(AutoRegisteringGroup): ) @click.option("--timeout", type=float, default=10, help="Timeout for indexing a single file.") def index(project: str, name: str | None, language: tuple[str, ...], log_level: str, timeout: float) -> None: - # Check if project.yml exists, if not auto-create it - yml_path = os.path.join(project, ProjectConfig.rel_path_to_project_yml()) - if not os.path.exists(yml_path): - click.echo(f"Project configuration not found at {yml_path}. Auto-creating...") + serena_config = SerenaConfig.from_config_file() + registered_project = serena_config.get_registered_project(project, autoregister=True) + if registered_project is None: + # Project not found; auto-create it + click.echo(f"No existing project found for '{project}'. Attempting auto-creation ...") try: - ProjectCommands._create_project(project, name, language) - except FileExistsError: - # Race condition - file was created between check and creation - pass - except ValueError as e: + registered_project = ProjectCommands._create_project(project, name, language) + except Exception as e: raise click.ClickException(str(e)) - ProjectCommands._index_project(project, log_level, timeout=timeout) + ProjectCommands._index_project(registered_project, log_level, timeout=timeout) @staticmethod - def _index_project(project: str, log_level: str, timeout: float) -> None: + def _index_project(registered_project: RegisteredProject, log_level: str, timeout: float) -> None: lvl = logging.getLevelNamesMapping()[log_level.upper()] logging.configure(level=lvl) serena_config = SerenaConfig.from_config_file() - proj = Project.load(os.path.abspath(project)) - click.echo(f"Indexing symbols in project {project} …") + proj = registered_project.get_project_instance() + 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 ) try: - log_file = os.path.join(project, ".serena", "logs", "indexing.txt") + log_file = os.path.join(proj.project_root, ".serena", "logs", "indexing.txt") files = proj.gather_source_files() diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 9523cf05..bde964b9 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -374,6 +374,14 @@ class RegisteredProject(ToStringMixin): project_instance=project_instance, ) + @classmethod + def from_project_root(cls, project_root: str | Path) -> "RegisteredProject": + project_config = ProjectConfig.load(project_root) + return RegisteredProject( + project_root=str(project_root), + project_config=project_config, + ) + def matches_root_path(self, path: str | Path) -> bool: """ Check if the given path matches the project root path. @@ -613,7 +621,12 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): def project_names(self) -> list[str]: return sorted(project.project_config.project_name for project in self.projects) - def get_registered_project(self, project_root_or_name: str) -> Optional[RegisteredProject]: + def get_registered_project(self, project_root_or_name: str, autoregister: bool = False) -> Optional[RegisteredProject]: + """ + :param project_root_or_name: path to the project root or the name of the project + :param autoregister: whether to register the project if it exists but is not registered yet + :return: the registered project, or None if not found + """ # look for project by name project_candidates = [] for project in self.projects: @@ -631,6 +644,14 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): for project in self.projects: if project.matches_root_path(project_root_or_name): 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) + if os.path.isfile(config_path): + registered_project = RegisteredProject.from_project_root(project_root_or_name) + self.add_registered_project(registered_project) + return registered_project + # nothing found return None def get_project(self, project_root_or_name: str) -> Optional["Project"]: @@ -640,6 +661,13 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): else: return registered_project.get_project_instance() + def add_registered_project(self, registered_project: RegisteredProject) -> None: + """ + Adds a registered project, saving the configuration file. + """ + self.projects.append(registered_project) + self.save() + def add_project_from_path(self, project_root: Path | str) -> "Project": """ Add a new project to the Serena configuration from a given path, auto-generating the project @@ -666,8 +694,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): project_config = ProjectConfig.load(project_root, autogenerate=True) new_project = Project(project_root=str(project_root), project_config=project_config, is_newly_created=True) - self.projects.append(RegisteredProject.from_project_instance(new_project)) - self.save() + self.add_registered_project(RegisteredProject.from_project_instance(new_project)) return new_project diff --git a/test/serena/test_cli_project_commands.py b/test/serena/test_cli_project_commands.py index 893299e6..1052d403 100644 --- a/test/serena/test_cli_project_commands.py +++ b/test/serena/test_cli_project_commands.py @@ -236,20 +236,20 @@ class TestProjectCreateHelper: def test_create_project_helper_returns_config(self, temp_project_dir): """Test that _create_project returns a ProjectConfig with explicit language.""" - config = ProjectCommands._create_project(temp_project_dir, "test-project", ("python",)) + config = ProjectCommands._create_project(temp_project_dir, "test-project", ("python",)).project_config assert isinstance(config, ProjectConfig) assert config.project_name == "test-project" def test_create_project_helper_with_auto_detect(self, temp_project_dir_with_python_file): """Test _create_project with auto-detected language.""" - config = ProjectCommands._create_project(temp_project_dir_with_python_file, "my-project", ()) + config = ProjectCommands._create_project(temp_project_dir_with_python_file, "my-project", ()).project_config assert isinstance(config, ProjectConfig) assert config.project_name == "my-project" assert len(config.languages) >= 1 def test_create_project_helper_with_languages(self, temp_project_dir): """Test _create_project with language specification.""" - config = ProjectCommands._create_project(temp_project_dir, None, ("python", "typescript")) + config = ProjectCommands._create_project(temp_project_dir, None, ("python", "typescript")).project_config assert isinstance(config, ProjectConfig) assert len(config.languages) >= 1