From 2b4fd66674969df6b3005b1f5b2555c06c6aa5c2 Mon Sep 17 00:00:00 2001 From: Adra <150769865+DraPraks@users.noreply.github.com> Date: Thu, 13 Nov 2025 21:22:37 +0700 Subject: [PATCH] feat: Added project create command with `--name`, `--index`, and auto-creation (#755) Previous command `generate_yml` has been removed --- docs/02-usage/020_running.md | 7 +- docs/02-usage/040_workflow.md | 6 +- docs/02-usage/050_configuration.md | 9 +- src/serena/cli.py | 78 ++++++- test/serena/test_cli_project_commands.py | 268 +++++++++++++++++++++++ 5 files changed, 347 insertions(+), 21 deletions(-) create mode 100644 test/serena/test_cli_project_commands.py diff --git a/docs/02-usage/020_running.md b/docs/02-usage/020_running.md index 1af40b13..f248a4c7 100644 --- a/docs/02-usage/020_running.md +++ b/docs/02-usage/020_running.md @@ -178,9 +178,12 @@ Here are some examples of commands you might find useful: tools description find_symbol # creating a new Serena project in the current directory - project generate-yml + project create -# indexing the project in the current directory +# creating and immediately indexing a project + project create --index + +# indexing the project in the current directory (auto-creates if needed) project index # run a health check on the project in the current directory diff --git a/docs/02-usage/040_workflow.md b/docs/02-usage/040_workflow.md index aca2143a..fcc65824 100644 --- a/docs/02-usage/040_workflow.md +++ b/docs/02-usage/040_workflow.md @@ -22,17 +22,19 @@ You can create a project either To explicitly create a project, use the following command while in the project directory: - project generate-yml [options] + project create [options] For instance, when using `uvx`, run - uvx --from git+https://github.com/oraios/serena serena project generate-yml [options] + uvx --from git+https://github.com/oraios/serena serena project create [options] * For an empty project, you will need to specify the programming language (e.g., `--language python`). * For an existing project, the main programming language will be detected automatically, but you can choose to explicitly specify multiple languages by passing the `--language` parameter multiple times (e.g. `--language python --language typescript`). + * You can optionally specify a custom project name with `--name "My Project"`. + * You can immediately index the project after creation with `--index`. After creation, you can adjust the project settings in the generated `.serena/project.yml` file. diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index bf1a85f7..11e363cb 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -22,13 +22,8 @@ Serena is configured in four places: See for a detailed explanation. You can override all entries from the `serena_config.yml` through command line arguments. 3. In the `.serena/project.yml` file within your project. This will hold project-level configuration that is used whenever that project is activated. This file will be autogenerated when you first use Serena on that project, but you can also - generate it explicitly with - - ```shell - uvx --from git+https://github.com/oraios/serena serena project generate-yml - ``` - - (or use the `--directory` command version). + create it explicitly with `serena project create [options]` (see the [Project Workflow](040_workflow.md#project-creation--indexing) + for details on available options). 4. Through the context and modes (see below). After the initial setup, continue with one of the sections below, depending on how you diff --git a/src/serena/cli.py b/src/serena/cli.py index fb78cd3d..178d643d 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -414,15 +414,21 @@ class ProjectCommands(AutoRegisteringGroup): ) @staticmethod - @click.command("generate-yml", help="Generate a project.yml file.") - @click.argument("project_path", type=click.Path(exists=True, file_okay=False), default=os.getcwd()) - @click.option( - "--language", type=str, multiple=True, help="Programming language(s); inferred if not specified. Can be passed multiple times." - ) - def generate_yml(project_path: str, language: tuple[str, ...]) -> None: + def _create_project(project_path: str, name: str | None, language: tuple[str, ...]) -> ProjectConfig: + """ + 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 + """ yml_path = os.path.join(project_path, ProjectConfig.rel_path_to_project_yml()) if os.path.exists(yml_path): raise FileExistsError(f"Project file {yml_path} already exists.") + languages: list[Language] = [] if language: for lang in language: @@ -431,12 +437,51 @@ class ProjectCommands(AutoRegisteringGroup): except ValueError: all_langs = [l.value for l in Language] raise ValueError(f"Unknown language '{lang}'. Supported: {all_langs}") - generated_conf = ProjectConfig.autogenerate(project_root=project_path, languages=languages if languages else None) - print(f"Generated project.yml with languages {generated_conf.languages} at {yml_path}.") + + generated_conf = ProjectConfig.autogenerate( + project_root=project_path, project_name=name, languages=languages if languages else None + ) + return generated_conf @staticmethod - @click.command("index", help="Index a project by saving symbols to the LSP cache.") + @click.command("create", help="Create a new Serena project configuration.") + @click.argument("project_path", type=click.Path(exists=True, file_okay=False), default=os.getcwd()) + @click.option("--name", type=str, default=None, help="Project name; defaults to directory name if not specified.") + @click.option( + "--language", type=str, multiple=True, help="Programming language(s); inferred if not specified. Can be passed multiple times." + ) + @click.option("--index", is_flag=True, help="Index the project after creation.") + @click.option( + "--log-level", + type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), + default="WARNING", + help="Log level for indexing (only used if --index is set).", + ) + @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: + generated_conf = ProjectCommands._create_project(project_path, name, language) + yml_path = os.path.join(project_path, ProjectConfig.rel_path_to_project_yml()) + click.echo(f"Generated project.yml with languages {generated_conf.languages} at {yml_path}.") + + if index: + click.echo("Indexing project...") + ProjectCommands._index_project(project_path, 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: + raise click.ClickException(str(e)) + + @staticmethod + @click.command("index", help="Index a project by saving symbols to the LSP cache. Auto-creates project.yml if it doesn't exist.") @click.argument("project", type=click.Path(exists=True), 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", + type=str, + multiple=True, + help="Programming language(s) (only used if auto-creating project.yml). Inferred if not specified.", + ) @click.option( "--log-level", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), @@ -444,7 +489,20 @@ class ProjectCommands(AutoRegisteringGroup): help="Log level for indexing.", ) @click.option("--timeout", type=float, default=10, help="Timeout for indexing a single file.") - def index(project: str, log_level: str, timeout: float) -> None: + 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...") + try: + generated_conf = ProjectCommands._create_project(project, name, language) + click.echo(f"Generated project.yml with languages {generated_conf.languages} at {yml_path}.") + except FileExistsError: + # Race condition - file was created between check and creation + pass + except ValueError as e: + raise click.ClickException(str(e)) + ProjectCommands._index_project(project, log_level, timeout=timeout) @staticmethod diff --git a/test/serena/test_cli_project_commands.py b/test/serena/test_cli_project_commands.py new file mode 100644 index 00000000..adf64670 --- /dev/null +++ b/test/serena/test_cli_project_commands.py @@ -0,0 +1,268 @@ +"""Tests for CLI project commands (create, index).""" + +import os +import shutil +import tempfile +import time + +import pytest +from click.testing import CliRunner + +from serena.cli import ProjectCommands +from serena.config.serena_config import ProjectConfig + +pytestmark = pytest.mark.filterwarnings("ignore::UserWarning") + + +@pytest.fixture +def temp_project_dir(): + """Create a temporary directory for testing.""" + tmpdir = tempfile.mkdtemp() + try: + yield tmpdir + finally: + # if windows, wait a bit to avoid PermissionError on cleanup + if os.name == "nt": + time.sleep(0.2) + shutil.rmtree(tmpdir, ignore_errors=True) + + +@pytest.fixture +def temp_project_dir_with_python_file(): + """Create a temporary directory with a Python file for testing.""" + tmpdir = tempfile.mkdtemp() + try: + # Create a simple Python file so language detection works + py_file = os.path.join(tmpdir, "test.py") + with open(py_file, "w") as f: + f.write("def hello():\n pass\n") + yield tmpdir + finally: + # if windows, wait a bit to avoid PermissionError on cleanup + if os.name == "nt": + time.sleep(0.2) + shutil.rmtree(tmpdir, ignore_errors=True) + + +@pytest.fixture +def cli_runner(): + """Create a CliRunner for testing Click commands.""" + return CliRunner() + + +class TestProjectCreate: + """Tests for 'project create' command.""" + + def test_create_basic_with_language(self, cli_runner, temp_project_dir): + """Test basic project creation with explicit language.""" + result = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--language", "python"]) + assert result.exit_code == 0, f"Command failed: {result.output}" + assert "Generated project.yml" in result.output + assert "python" in result.output.lower() + + # Verify project.yml was created + yml_path = os.path.join(temp_project_dir, ".serena", "project.yml") + assert os.path.exists(yml_path), f"project.yml not found at {yml_path}" + + def test_create_auto_detect_language(self, cli_runner, temp_project_dir_with_python_file): + """Test project creation with auto-detected language.""" + result = cli_runner.invoke(ProjectCommands.create, [temp_project_dir_with_python_file]) + assert result.exit_code == 0, f"Command failed: {result.output}" + assert "Generated project.yml" in result.output + assert "python" in result.output.lower() + + # Verify project.yml was created + yml_path = os.path.join(temp_project_dir_with_python_file, ".serena", "project.yml") + assert os.path.exists(yml_path) + + def test_create_with_name(self, cli_runner, temp_project_dir): + """Test project creation with custom name and explicit language.""" + result = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--name", "my-custom-project", "--language", "python"]) + assert result.exit_code == 0, f"Command failed: {result.output}" + assert "Generated project.yml" in result.output + + # Verify project.yml was created + yml_path = os.path.join(temp_project_dir, ".serena", "project.yml") + assert os.path.exists(yml_path) + + def test_create_with_language(self, cli_runner, temp_project_dir): + """Test project creation with specified language.""" + result = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--language", "python"]) + assert result.exit_code == 0, f"Command failed: {result.output}" + assert "Generated project.yml" in result.output + assert "python" in result.output.lower() + + def test_create_with_multiple_languages(self, cli_runner, temp_project_dir): + """Test project creation with multiple languages.""" + result = cli_runner.invoke( + ProjectCommands.create, + [temp_project_dir, "--language", "python", "--language", "typescript"], + ) + assert result.exit_code == 0, f"Command failed: {result.output}" + assert "Generated project.yml" in result.output + + def test_create_with_invalid_language(self, cli_runner, temp_project_dir): + """Test project creation with invalid language raises error.""" + result = cli_runner.invoke( + ProjectCommands.create, + [temp_project_dir, "--language", "invalid-lang"], + ) + assert result.exit_code != 0, "Should fail with invalid language" + assert "Unknown language" in result.output or "invalid-lang" in result.output + + def test_create_already_exists(self, cli_runner, temp_project_dir): + """Test that creating a project twice fails gracefully.""" + # Create once with explicit language + result1 = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--language", "python"]) + assert result1.exit_code == 0 + + # Try to create again - should fail gracefully + result2 = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--language", "python"]) + assert result2.exit_code != 0, "Should fail when project.yml already exists" + assert "already exists" in result2.output.lower() + assert "Error:" in result2.output # Should be user-friendly error + + def test_create_with_index_flag(self, cli_runner, temp_project_dir_with_python_file): + """Test project creation with --index flag performs indexing.""" + result = cli_runner.invoke( + ProjectCommands.create, + [temp_project_dir_with_python_file, "--language", "python", "--index", "--log-level", "ERROR", "--timeout", "5"], + ) + assert result.exit_code == 0, f"Command failed: {result.output}" + assert "Generated project.yml" in result.output + assert "Indexing project" in result.output + + # Verify project.yml was created + yml_path = os.path.join(temp_project_dir_with_python_file, ".serena", "project.yml") + assert os.path.exists(yml_path) + + # Verify cache directory was created (proof of indexing) + cache_dir = os.path.join(temp_project_dir_with_python_file, ".serena", "cache") + assert os.path.exists(cache_dir), "Cache directory should exist after indexing" + + def test_create_without_index_flag(self, cli_runner, temp_project_dir): + """Test that project creation without --index does NOT perform indexing.""" + result = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--language", "python"]) + assert result.exit_code == 0 + assert "Generated project.yml" in result.output + assert "Indexing" not in result.output + + # Verify cache directory was NOT created + cache_dir = os.path.join(temp_project_dir, ".serena", "cache") + assert not os.path.exists(cache_dir), "Cache directory should not exist without --index" + + +class TestProjectIndex: + """Tests for 'project index' command.""" + + def test_index_auto_creates_project_with_files(self, cli_runner, temp_project_dir_with_python_file): + """Test that index command auto-creates project.yml if it doesn't exist (with source files).""" + result = cli_runner.invoke(ProjectCommands.index, [temp_project_dir_with_python_file, "--log-level", "ERROR", "--timeout", "5"]) + # Should succeed and perform indexing + assert result.exit_code == 0, f"Command failed: {result.output}" + assert "Auto-creating" in result.output or "Indexing" in result.output + + # Verify project.yml was auto-created + yml_path = os.path.join(temp_project_dir_with_python_file, ".serena", "project.yml") + assert os.path.exists(yml_path), "project.yml should be auto-created" + + def test_index_with_explicit_language(self, cli_runner, temp_project_dir): + """Test index with explicit --language for empty directory.""" + result = cli_runner.invoke( + ProjectCommands.index, + [temp_project_dir, "--language", "python", "--log-level", "ERROR", "--timeout", "5"], + ) + # Should succeed even without source files if language is explicit + assert result.exit_code == 0, f"Command failed: {result.output}" + + yml_path = os.path.join(temp_project_dir, ".serena", "project.yml") + assert os.path.exists(yml_path) + + def test_index_with_language_auto_creates(self, cli_runner, temp_project_dir): + """Test index with --language option for auto-creation.""" + result = cli_runner.invoke( + ProjectCommands.index, + [temp_project_dir, "--language", "python", "--log-level", "ERROR"], + ) + assert result.exit_code == 0 or "Indexing" in result.output + + yml_path = os.path.join(temp_project_dir, ".serena", "project.yml") + assert os.path.exists(yml_path) + + def test_index_is_equivalent_to_create_with_index(self, cli_runner, temp_project_dir_with_python_file): + """Test that 'index' behaves like 'create --index' for new projects.""" + # Use manual temp directory creation with Windows-safe cleanup + # to avoid PermissionError on Windows CI when language servers hold file locks + dir1 = tempfile.mkdtemp() + dir2 = tempfile.mkdtemp() + + try: + # Setup both directories with same file + for d in [dir1, dir2]: + with open(os.path.join(d, "test.py"), "w") as f: + f.write("def hello():\n pass\n") + + # Run 'create --index' on dir1 + result1 = cli_runner.invoke( + ProjectCommands.create, [dir1, "--language", "python", "--index", "--log-level", "ERROR", "--timeout", "5"] + ) + + # Run 'index' on dir2 + result2 = cli_runner.invoke(ProjectCommands.index, [dir2, "--language", "python", "--log-level", "ERROR", "--timeout", "5"]) + + # Both should succeed + assert result1.exit_code == 0, f"create --index failed: {result1.output}" + assert result2.exit_code == 0, f"index failed: {result2.output}" + + # Both should create project.yml + assert os.path.exists(os.path.join(dir1, ".serena", "project.yml")) + assert os.path.exists(os.path.join(dir2, ".serena", "project.yml")) + + # Both should create cache (proof of indexing) + assert os.path.exists(os.path.join(dir1, ".serena", "cache")) + assert os.path.exists(os.path.join(dir2, ".serena", "cache")) + finally: + # Windows-safe cleanup: wait for file handles to be released + if os.name == "nt": + time.sleep(0.2) + # Use ignore_errors to handle lingering file locks on Windows + shutil.rmtree(dir1, ignore_errors=True) + shutil.rmtree(dir2, ignore_errors=True) + + +class TestProjectCreateHelper: + """Tests for _create_project helper method.""" + + 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",)) + 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", ()) + 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")) + assert isinstance(config, ProjectConfig) + assert len(config.languages) >= 1 + + def test_create_project_helper_file_exists_error(self, temp_project_dir): + """Test _create_project raises error if project.yml exists.""" + # Create project first with explicit language + ProjectCommands._create_project(temp_project_dir, None, ("python",)) + + # Try to create again - should raise FileExistsError + with pytest.raises(FileExistsError): + ProjectCommands._create_project(temp_project_dir, None, ("python",)) + + +if __name__ == "__main__": + # For manual testing, you can run this file directly: + # uv run pytest test/serena/test_cli_project_commands.py -v + pytest.main([__file__, "-v"])