Files

553 lines
19 KiB
Python

"""Unit tests for shared CLI helpers."""
from __future__ import annotations
import io
from pathlib import Path
import pytest
from cocoindex_code import cli
from cocoindex_code.cli import (
add_to_gitignore,
remove_from_gitignore,
require_project_root,
resolve_default_path,
)
from cocoindex_code.protocol import SearchResponse, SearchResult
def test_print_search_results_replaces_unencodable_console_characters(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Search output stays usable when the console cannot encode a result."""
raw_output = io.BytesIO()
gbk_stdout = io.TextIOWrapper(raw_output, encoding="gbk", errors="strict")
monkeypatch.setattr(cli.sys, "stdout", gbk_stdout)
response = SearchResponse(
success=True,
results=[
SearchResult(
file_path="notes↔.md",
language="markdown",
content="可编码内容: left ↔ right",
start_line=1,
end_line=1,
score=0.9,
)
],
)
cli.print_search_results(response)
gbk_stdout.flush()
output = raw_output.getvalue().decode("gbk")
assert "可编码内容" in output
assert "File: notes?.md" in output
assert "left ? right" in output
def test_require_project_root_success(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
project = tmp_path / "project"
(project / ".cocoindex_code").mkdir(parents=True)
(project / ".cocoindex_code" / "settings.yml").write_text("include_patterns: []")
subdir = project / "src"
subdir.mkdir()
monkeypatch.chdir(subdir)
# Create global settings so require_project_root doesn't reject
settings_dir = tmp_path / "ccc_home"
settings_dir.mkdir()
(settings_dir / "global_settings.yml").write_text(
"embedding:\n model: test\n provider: litellm\n"
)
monkeypatch.setenv("COCOINDEX_CODE_DIR", str(settings_dir))
assert require_project_root() == project
def test_require_project_root_exits_when_not_initialized(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
standalone = tmp_path / "standalone"
standalone.mkdir()
monkeypatch.chdir(standalone)
# Create global settings so we test the "no project" check, not "no global settings"
settings_dir = tmp_path / "ccc_home"
settings_dir.mkdir()
(settings_dir / "global_settings.yml").write_text(
"embedding:\n model: test\n provider: litellm\n"
)
monkeypatch.setenv("COCOINDEX_CODE_DIR", str(settings_dir))
from click.exceptions import Exit
with pytest.raises(Exit):
require_project_root()
def test_require_project_root_auto_init_at_git_root(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""With auto_init, a missing project is initialized at the enclosing git root."""
repo = tmp_path / "repo"
(repo / ".git").mkdir(parents=True)
subdir = repo / "src"
subdir.mkdir()
monkeypatch.chdir(subdir)
settings_dir = tmp_path / "ccc_home"
settings_dir.mkdir()
(settings_dir / "global_settings.yml").write_text(
"embedding:\n model: test\n provider: litellm\n"
)
monkeypatch.setenv("COCOINDEX_CODE_DIR", str(settings_dir))
assert require_project_root(auto_init=True) == repo
assert (repo / ".cocoindex_code" / "settings.yml").is_file()
assert "/.cocoindex_code/" in (repo / ".gitignore").read_text()
assert "Created project settings" in capsys.readouterr().out
def test_require_project_root_auto_init_falls_back_to_cwd(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Without a parent git root, auto_init initializes the current directory."""
standalone = tmp_path / "standalone"
standalone.mkdir()
monkeypatch.chdir(standalone)
settings_dir = tmp_path / "ccc_home"
settings_dir.mkdir()
(settings_dir / "global_settings.yml").write_text(
"embedding:\n model: test\n provider: litellm\n"
)
monkeypatch.setenv("COCOINDEX_CODE_DIR", str(settings_dir))
assert require_project_root(auto_init=True) == standalone
assert (standalone / ".cocoindex_code" / "settings.yml").is_file()
def test_require_project_root_auto_init_global_settings_non_tty_errors(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Without a TTY, auto_init never creates global settings — scripts must not
silently commit to a default embedding model."""
standalone = tmp_path / "standalone"
standalone.mkdir()
monkeypatch.chdir(standalone)
monkeypatch.setenv("COCOINDEX_CODE_DIR", str(tmp_path / "no_ccc_home"))
monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: False)
from click.exceptions import Exit
with pytest.raises(Exit):
require_project_root(auto_init=True)
assert not (standalone / ".cocoindex_code").exists()
def test_require_project_root_auto_init_global_settings_tty_runs_init_setup(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""On a TTY, auto_init runs the same interactive model setup as `ccc init`
for missing global settings, then proceeds to initialize the project."""
standalone = tmp_path / "standalone"
standalone.mkdir()
monkeypatch.chdir(standalone)
settings_dir = tmp_path / "ccc_home"
monkeypatch.setenv("COCOINDEX_CODE_DIR", str(settings_dir))
monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: True)
def fake_setup(litellm_model_flag: str | None) -> None:
settings_dir.mkdir()
(settings_dir / "global_settings.yml").write_text(
"embedding:\n model: test\n provider: litellm\n"
)
monkeypatch.setattr(cli, "_setup_user_settings_interactive", fake_setup)
assert require_project_root(auto_init=True) == standalone
assert (settings_dir / "global_settings.yml").is_file()
assert (standalone / ".cocoindex_code" / "settings.yml").is_file()
def test_resolve_default_path_from_subdirectory(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
project_root = tmp_path / "project"
subdir = project_root / "src" / "lib"
subdir.mkdir(parents=True)
monkeypatch.chdir(subdir)
result = resolve_default_path(project_root)
assert result == "src/lib/*"
def test_resolve_default_path_from_project_root(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
project_root = tmp_path / "project"
project_root.mkdir()
monkeypatch.chdir(project_root)
result = resolve_default_path(project_root)
assert result is None
def test_resolve_default_path_outside_project(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
project_root = tmp_path / "project"
project_root.mkdir()
other = tmp_path / "other"
other.mkdir()
monkeypatch.chdir(other)
result = resolve_default_path(project_root)
assert result is None
# ---------------------------------------------------------------------------
# .gitignore helpers
# ---------------------------------------------------------------------------
def test_add_to_gitignore_creates_file(tmp_path: Path) -> None:
(tmp_path / ".git").mkdir()
add_to_gitignore(tmp_path)
gitignore = tmp_path / ".gitignore"
assert gitignore.is_file()
content = gitignore.read_text()
assert "# CocoIndex Code (ccc)" in content
assert "/.cocoindex_code/" in content
def test_add_to_gitignore_appends_to_existing(tmp_path: Path) -> None:
(tmp_path / ".git").mkdir()
gitignore = tmp_path / ".gitignore"
gitignore.write_text("*.pyc\n")
add_to_gitignore(tmp_path)
content = gitignore.read_text()
assert "*.pyc" in content
assert "/.cocoindex_code/" in content
def test_add_to_gitignore_idempotent(tmp_path: Path) -> None:
(tmp_path / ".git").mkdir()
gitignore = tmp_path / ".gitignore"
gitignore.write_text("/.cocoindex_code/\n")
add_to_gitignore(tmp_path)
content = gitignore.read_text()
assert content.count("/.cocoindex_code/") == 1
def test_add_to_gitignore_skips_when_no_git(tmp_path: Path) -> None:
add_to_gitignore(tmp_path)
assert not (tmp_path / ".gitignore").exists()
def test_remove_from_gitignore(tmp_path: Path) -> None:
gitignore = tmp_path / ".gitignore"
gitignore.write_text("*.pyc\n# CocoIndex Code (ccc)\n/.cocoindex_code/\n__pycache__/\n")
remove_from_gitignore(tmp_path)
content = gitignore.read_text()
assert "/.cocoindex_code/" not in content
assert "# CocoIndex Code (ccc)" not in content
assert "*.pyc" in content
assert "__pycache__/" in content
def test_remove_from_gitignore_no_entry(tmp_path: Path) -> None:
gitignore = tmp_path / ".gitignore"
original = "*.pyc\n__pycache__/\n"
gitignore.write_text(original)
remove_from_gitignore(tmp_path)
assert gitignore.read_text() == original
# ---------------------------------------------------------------------------
# COCOINDEX_CODE_HOST_CWD callback
# ---------------------------------------------------------------------------
def test_apply_host_cwd_chdirs_to_mapped_path(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""When COCOINDEX_CODE_HOST_CWD is set and matches the mapping, chdir to container form."""
from cocoindex_code.cli import _apply_host_cwd
from cocoindex_code.settings import _reset_host_path_mapping_cache
container = tmp_path / "workspace"
host = tmp_path / "host-home"
(container / "proj" / "src").mkdir(parents=True)
host.mkdir()
_reset_host_path_mapping_cache()
monkeypatch.setenv("COCOINDEX_CODE_HOST_PATH_MAPPING", f"{container}={host}")
monkeypatch.setenv("COCOINDEX_CODE_HOST_CWD", str(host / "proj" / "src"))
_apply_host_cwd()
# chdir resolves symlinks; compare resolved forms.
assert Path.cwd().resolve() == (container / "proj" / "src").resolve()
assert capsys.readouterr().err == ""
_reset_host_path_mapping_cache()
def test_apply_host_cwd_warns_on_invalid_path(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""An invalid COCOINDEX_CODE_HOST_CWD emits a warning but doesn't abort."""
from cocoindex_code.cli import _apply_host_cwd
original_cwd = Path.cwd()
monkeypatch.setenv("COCOINDEX_CODE_HOST_CWD", "/nonexistent/path/xyz")
monkeypatch.delenv("COCOINDEX_CODE_HOST_PATH_MAPPING", raising=False)
_apply_host_cwd()
captured = capsys.readouterr()
assert "COCOINDEX_CODE_HOST_CWD" in captured.err
assert "/nonexistent/path/xyz" in captured.err
# cwd should be unchanged since chdir failed.
assert Path.cwd() == original_cwd
def test_apply_host_cwd_noop_when_unset(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""With COCOINDEX_CODE_HOST_CWD unset, the callback is a silent no-op."""
from cocoindex_code.cli import _apply_host_cwd
original_cwd = Path.cwd()
monkeypatch.delenv("COCOINDEX_CODE_HOST_CWD", raising=False)
_apply_host_cwd()
assert Path.cwd() == original_cwd
assert capsys.readouterr().err == ""
# ---------------------------------------------------------------------------
# ccc version
# ---------------------------------------------------------------------------
def test_version_prints_client_version() -> None:
"""`ccc version` reports the client version and exits 0."""
from typer.testing import CliRunner
from cocoindex_code._version import __version__
from cocoindex_code.cli import app
result = CliRunner().invoke(app, ["version"], catch_exceptions=False)
assert result.exit_code == 0
assert result.stdout.strip() == __version__
def test_version_works_outside_a_project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""No project discovery, no daemon, no settings needed."""
from typer.testing import CliRunner
from cocoindex_code._version import __version__
from cocoindex_code.cli import app
standalone = tmp_path / "not-a-project"
standalone.mkdir()
monkeypatch.chdir(standalone)
monkeypatch.setenv("COCOINDEX_CODE_DIR", str(tmp_path / "no-such-home"))
result = CliRunner().invoke(app, ["version"], catch_exceptions=False)
assert result.exit_code == 0
assert result.stdout.strip() == __version__
# ---------------------------------------------------------------------------
# ccc init — auto-populate indexing_params / query_params from curated table
# ---------------------------------------------------------------------------
def test_init_auto_populates_known_model(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""For a known model, `ccc init` writes real indexing/query params into the
file and prints an 'Applied recommended defaults' message.
"""
from cocoindex_code.settings import EmbeddingSettings, load_user_settings
user_dir = tmp_path / ".cocoindex_code"
monkeypatch.setenv("COCOINDEX_CODE_DIR", str(user_dir))
monkeypatch.setattr(
cli,
"_resolve_embedding_choice",
lambda **_kw: EmbeddingSettings(provider="litellm", model="cohere/embed-english-v3.0"),
)
monkeypatch.setattr(cli, "_run_init_model_check", lambda: True)
cli._setup_user_settings_interactive(litellm_model_flag=None)
loaded = load_user_settings()
assert loaded.embedding.provider == "litellm"
assert loaded.embedding.model == "cohere/embed-english-v3.0"
assert loaded.embedding.indexing_params == {"input_type": "search_document"}
assert loaded.embedding.query_params == {"input_type": "search_query"}
out = capsys.readouterr().out
assert "Applied recommended defaults" in out
def test_init_writes_comment_template_for_unknown_model(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""For a model outside the curated table, `ccc init` writes a commented-out
template block under ``embedding:`` instead of real keys.
"""
from cocoindex_code.settings import (
EmbeddingSettings,
load_user_settings,
user_settings_path,
)
user_dir = tmp_path / ".cocoindex_code"
monkeypatch.setenv("COCOINDEX_CODE_DIR", str(user_dir))
monkeypatch.setattr(
cli,
"_resolve_embedding_choice",
lambda **_kw: EmbeddingSettings(provider="litellm", model="someprovider/unknown-model"),
)
monkeypatch.setattr(cli, "_run_init_model_check", lambda: True)
cli._setup_user_settings_interactive(litellm_model_flag=None)
content = user_settings_path().read_text()
# Commented template present, no populated keys
assert "# indexing_params: {}" in content
assert "# query_params: {}" in content
loaded = load_user_settings()
assert loaded.embedding.indexing_params is None
assert loaded.embedding.query_params is None
def test_init_failed_check_prints_next_steps_and_keeps_settings(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""When the model check fails and we can't re-prompt (non-interactive), the
settings file is kept and a 'Next steps' recovery block is printed.
"""
from cocoindex_code.settings import EmbeddingSettings, user_settings_path
user_dir = tmp_path / ".cocoindex_code"
monkeypatch.setenv("COCOINDEX_CODE_DIR", str(user_dir))
monkeypatch.setattr(
cli,
"_resolve_embedding_choice",
lambda **_kw: EmbeddingSettings(provider="litellm", model="someprovider/unknown-model"),
)
monkeypatch.setattr(cli, "_run_init_model_check", lambda: False)
# Non-interactive: no retry prompt, falls straight through to next steps.
monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: False)
cli._setup_user_settings_interactive(litellm_model_flag=None)
err = capsys.readouterr().err
assert "Next steps" in err
assert "ccc doctor" in err
# Settings are kept on disk so the user can edit them.
assert user_settings_path().is_file()
def test_resolve_embedding_choice_prefills_previous_on_retry(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""On retry, last attempt's provider and model become the prompt defaults."""
import questionary
from cocoindex_code.settings import EmbeddingSettings
captured: dict[str, object] = {}
class _FakeQuestion:
def __init__(self, value: object) -> None:
self._value = value
def ask(self) -> object:
return self._value
def _fake_select(
message: str, choices: object, default: object = None, **_kw: object
) -> _FakeQuestion:
captured["select_default"] = default
return _FakeQuestion("litellm")
def _fake_text(message: str, default: str = "", **_kw: object) -> _FakeQuestion:
captured["text_default"] = default
return _FakeQuestion("openai/text-embedding-3-small")
monkeypatch.setattr(questionary, "select", _fake_select)
monkeypatch.setattr(questionary, "text", _fake_text)
previous = EmbeddingSettings(provider="litellm", model="ollama/nomic-embed-text")
result = cli._resolve_embedding_choice(
litellm_model_flag=None,
st_installed=True,
tty=True,
previous=previous,
)
# Provider select is pre-pointed at last time's provider; model is pre-filled.
assert captured["select_default"] == "litellm"
assert captured["text_default"] == "ollama/nomic-embed-text"
assert result.provider == "litellm"
assert result.model == "openai/text-embedding-3-small"
def test_st_model_rejection_reason_flags_ollama_prefix() -> None:
"""`ollama/` models can't be used with sentence-transformers; valid HF ids pass."""
reason = cli._st_model_rejection_reason("ollama/nomic-embed-text")
assert reason is not None and "litellm" in reason
# Case-insensitive and whitespace-tolerant.
assert cli._st_model_rejection_reason(" OLLAMA/foo ") is not None
# Real HuggingFace ids with an `org/` slash must not false-positive.
assert cli._st_model_rejection_reason("Snowflake/snowflake-arctic-embed-xs") is None
assert cli._st_model_rejection_reason("openai/clip-vit-base-patch32") is None
def test_resolve_embedding_choice_validates_st_model(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The sentence-transformers model prompt gets a validator that rejects
`ollama/` models inline (before anything is written or tested)."""
import questionary
captured: dict[str, object] = {}
class _FakeQuestion:
def __init__(self, value: object) -> None:
self._value = value
def ask(self) -> object:
return self._value
def _fake_select(
message: str, choices: object, default: object = None, **_kw: object
) -> _FakeQuestion:
return _FakeQuestion("sentence-transformers")
def _fake_text(
message: str, default: str = "", validate: object = None, **_kw: object
) -> _FakeQuestion:
captured["validate"] = validate
return _FakeQuestion("Snowflake/snowflake-arctic-embed-xs")
monkeypatch.setattr(questionary, "select", _fake_select)
monkeypatch.setattr(questionary, "text", _fake_text)
cli._resolve_embedding_choice(litellm_model_flag=None, st_installed=True, tty=True)
validate = captured["validate"]
assert callable(validate)
assert validate("ollama/nomic-embed-text") is not True # rejected (returns message)
assert validate("Snowflake/snowflake-arctic-embed-xs") is True