feat: Added project create command with --name, --index, and auto-creation (#755)

Previous command `generate_yml` has been removed
This commit is contained in:
Adra authored and GitHub committed 2025-11-13 15:22:37 +01:00
1 parent 2d7527bcde
commit 2b4fd66674
5 files changed
+347 -21

No files matched your search

+5 -2
View File
@@ -178,9 +178,12 @@ Here are some examples of commands you might find useful:
<serena> tools description find_symbol
# creating a new Serena project in the current directory
<serena> project generate-yml
<serena> project create
# indexing the project in the current directory
# creating and immediately indexing a project
<serena> project create --index
# indexing the project in the current directory (auto-creates if needed)
<serena> project index
# run a health check on the project in the current directory
+4 -2
View File
@@ -22,17 +22,19 @@ You can create a project either
To explicitly create a project, use the following command while in the project directory:
<serena> project generate-yml [options]
<serena> 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.
+2 -7
View File
@@ -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
+68 -10
View File
@@ -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
+268
View File
@@ -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"])