mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-14 12:17:02 +00:00
feat: Added project create command with --name, --index, and auto-creation (#755)
Previous command `generate_yml` has been removed
This commit is contained in:
5 files changed
+347
-21
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
Reference in new issue
Block a user