Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 107 additions & 11 deletions src/cocoindex_code/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,9 @@ class UserSettings:
embedding: EmbeddingSettings
envs: dict[str, str] = field(default_factory=dict)
daemon: DaemonSettings = field(default_factory=DaemonSettings)
include_patterns: list[str] | None = None
exclude_patterns: list[str] | None = None
max_file_size: int | None = None


@dataclass
Expand Down Expand Up @@ -180,8 +183,33 @@ def default_user_settings() -> UserSettings:
)


def default_project_settings() -> ProjectSettings:
return ProjectSettings()
def default_project_settings(user_settings: UserSettings | None = None) -> ProjectSettings:
if user_settings is None:
try:
user_settings = load_user_settings()
except Exception:
user_settings = None

include_patterns = (
list(user_settings.include_patterns)
if user_settings is not None and user_settings.include_patterns is not None
else list(DEFAULT_INCLUDED_PATTERNS)
)
exclude_patterns = (
list(user_settings.exclude_patterns)
if user_settings is not None and user_settings.exclude_patterns is not None
else list(DEFAULT_EXCLUDED_PATTERNS)
)
max_file_size = (
user_settings.max_file_size
if user_settings is not None and user_settings.max_file_size is not None
else None
)
return ProjectSettings(
include_patterns=include_patterns,
exclude_patterns=exclude_patterns,
max_file_size=max_file_size,
)


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -526,6 +554,12 @@ def _user_settings_to_dict(settings: UserSettings) -> dict[str, Any]:
daemon_dict["keep_alive_with_mcp"] = settings.daemon.keep_alive_with_mcp
if daemon_dict:
d["daemon"] = daemon_dict
if settings.include_patterns is not None:
d["include_patterns"] = list(settings.include_patterns)
if settings.exclude_patterns is not None:
d["exclude_patterns"] = list(settings.exclude_patterns)
if settings.max_file_size is not None:
d["max_file_size"] = settings.max_file_size
return d


Expand Down Expand Up @@ -565,7 +599,34 @@ def _user_settings_from_dict(d: dict[str, Any]) -> UserSettings:
raise ValueError("daemon.keep_alive_with_mcp must be a boolean")
daemon_kwargs["keep_alive_with_mcp"] = keep_alive_with_mcp
daemon = DaemonSettings(**daemon_kwargs)
return UserSettings(embedding=embedding, envs=envs, daemon=daemon)

include_patterns = d.get("include_patterns")
if include_patterns is not None:
if not isinstance(include_patterns, list) or not all(
isinstance(x, str) for x in include_patterns
):
raise ValueError("include_patterns must be a list of strings")
include_patterns = list(include_patterns)

exclude_patterns = d.get("exclude_patterns")
if exclude_patterns is not None:
if not isinstance(exclude_patterns, list) or not all(
isinstance(x, str) for x in exclude_patterns
):
raise ValueError("exclude_patterns must be a list of strings")
exclude_patterns = list(exclude_patterns)

raw_max_size = d.get("max_file_size")
max_file_size = None if raw_max_size is None else parse_file_size(raw_max_size)

return UserSettings(
embedding=embedding,
envs=envs,
daemon=daemon,
include_patterns=include_patterns,
exclude_patterns=exclude_patterns,
max_file_size=max_file_size,
)


_SIZE_UNITS: dict[str, int] = {
Expand Down Expand Up @@ -628,16 +689,43 @@ def _project_settings_to_dict(settings: ProjectSettings) -> dict[str, Any]:
return d


def _project_settings_from_dict(d: dict[str, Any]) -> ProjectSettings:
def _project_settings_from_dict(
d: dict[str, Any],
user_settings: UserSettings | None = None,
) -> ProjectSettings:
overrides = [
LanguageOverride(ext=lo["ext"], lang=lo["lang"]) for lo in d.get("language_overrides", [])
]
chunkers = [ChunkerMapping(ext=cm["ext"], module=cm["module"]) for cm in d.get("chunkers", [])]
raw_max_size = d.get("max_file_size")
max_file_size = None if raw_max_size is None else parse_file_size(raw_max_size)

# Include patterns: project > user > default
if "include_patterns" in d and d["include_patterns"] is not None:
include_patterns = list(d["include_patterns"])
elif user_settings is not None and user_settings.include_patterns is not None:
include_patterns = list(user_settings.include_patterns)
else:
include_patterns = list(DEFAULT_INCLUDED_PATTERNS)

# Exclude patterns: project > user > default
if "exclude_patterns" in d and d["exclude_patterns"] is not None:
exclude_patterns = list(d["exclude_patterns"])
elif user_settings is not None and user_settings.exclude_patterns is not None:
exclude_patterns = list(user_settings.exclude_patterns)
else:
exclude_patterns = list(DEFAULT_EXCLUDED_PATTERNS)

# Max file size: project > user > None
if "max_file_size" in d:
raw_max_size = d.get("max_file_size")
max_file_size = None if raw_max_size is None else parse_file_size(raw_max_size)
elif user_settings is not None and user_settings.max_file_size is not None:
max_file_size = user_settings.max_file_size
else:
max_file_size = None

return ProjectSettings(
include_patterns=d.get("include_patterns", list(DEFAULT_INCLUDED_PATTERNS)),
exclude_patterns=d.get("exclude_patterns", list(DEFAULT_EXCLUDED_PATTERNS)),
include_patterns=include_patterns,
exclude_patterns=exclude_patterns,
language_overrides=overrides,
chunkers=chunkers,
max_file_size=max_file_size,
Expand Down Expand Up @@ -757,20 +845,28 @@ def save_initial_user_settings(
return path


def load_project_settings(project_root: Path) -> ProjectSettings:
def load_project_settings(
project_root: Path,
user_settings: UserSettings | None = None,
) -> ProjectSettings:
"""Read ``$PROJECT_ROOT/.cocoindex_code/settings.yml``.

Raises ``FileNotFoundError`` if the file does not exist.
"""
path = project_settings_path(project_root)
if not path.is_file():
raise FileNotFoundError(f"Project settings not found: {path}")
if user_settings is None:
try:
user_settings = load_user_settings()
except Exception:
user_settings = None
try:
with open(path) as f:
data = _yaml.safe_load(f)
if not data:
return default_project_settings()
return _project_settings_from_dict(data)
return default_project_settings(user_settings=user_settings)
return _project_settings_from_dict(data, user_settings=user_settings)
except Exception as e:
raise type(e)(f"Error loading {path}: {e}") from e

Expand Down
85 changes: 85 additions & 0 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
_reset_db_path_mapping_cache,
_reset_host_path_mapping_cache,
_user_settings_from_dict,
_user_settings_to_dict,
default_project_settings,
default_user_settings,
find_parent_with_marker,
Expand Down Expand Up @@ -909,3 +910,87 @@ def test_project_settings_parses_human_readable_max_file_size(tmp_path: Path) ->
path = save_project_settings(tmp_path, ProjectSettings())
path.write_text(path.read_text() + "\nmax_file_size: 500KB\n")
assert load_project_settings(tmp_path).max_file_size == 500 * 1024


def test_user_settings_round_trip_include_exclude_and_max_file_size() -> None:
settings = UserSettings(
embedding=EmbeddingSettings(provider="litellm", model="custom-model"),
include_patterns=["**/*.py", "**/*.rs"],
exclude_patterns=["**/raw/**", "**/imgs/**"],
max_file_size=1024 * 1024,
)
d = _user_settings_to_dict(settings)
assert d["include_patterns"] == ["**/*.py", "**/*.rs"]
assert d["exclude_patterns"] == ["**/raw/**", "**/imgs/**"]
assert d["max_file_size"] == 1024 * 1024

loaded = _user_settings_from_dict(d)
assert loaded.include_patterns == ["**/*.py", "**/*.rs"]
assert loaded.exclude_patterns == ["**/raw/**", "**/imgs/**"]
assert loaded.max_file_size == 1024 * 1024


def test_user_settings_rejects_invalid_patterns() -> None:
base = {"embedding": {"provider": "litellm", "model": "m"}}
with pytest.raises(ValueError, match="include_patterns must be a list of strings"):
_user_settings_from_dict({**base, "include_patterns": "not-a-list"})

with pytest.raises(ValueError, match="include_patterns must be a list of strings"):
_user_settings_from_dict({**base, "include_patterns": [123]})

with pytest.raises(ValueError, match="exclude_patterns must be a list of strings"):
_user_settings_from_dict({**base, "exclude_patterns": "not-a-list"})

with pytest.raises(ValueError, match="exclude_patterns must be a list of strings"):
_user_settings_from_dict({**base, "exclude_patterns": [None]})


def test_project_settings_inherits_user_settings_defaults(tmp_path: Path) -> None:
user_settings = UserSettings(
embedding=EmbeddingSettings(provider="litellm", model="m"),
include_patterns=["**/*.py", "**/*.ts"],
exclude_patterns=["**/build/**", "**/raw/**"],
max_file_size=200 * 1024,
)
# Project settings file without patterns or max_file_size
path = tmp_path / ".cocoindex_code" / "settings.yml"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("language_overrides: []\n")

loaded = load_project_settings(tmp_path, user_settings=user_settings)
assert loaded.include_patterns == ["**/*.py", "**/*.ts"]
assert loaded.exclude_patterns == ["**/build/**", "**/raw/**"]
assert loaded.max_file_size == 200 * 1024


def test_project_settings_overrides_user_settings_defaults(tmp_path: Path) -> None:
user_settings = UserSettings(
embedding=EmbeddingSettings(provider="litellm", model="m"),
include_patterns=["**/*.py"],
exclude_patterns=["**/global_exclude/**"],
max_file_size=200 * 1024,
)
# Project settings overrides exclude_patterns and max_file_size
path = tmp_path / ".cocoindex_code" / "settings.yml"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("exclude_patterns:\n - '**/local_exclude/**'\nmax_file_size: 50KB\n")

loaded = load_project_settings(tmp_path, user_settings=user_settings)
# include_patterns was not set in project, so it inherits user settings
assert loaded.include_patterns == ["**/*.py"]
# exclude_patterns was set in project, so it completely replaces user settings
assert loaded.exclude_patterns == ["**/local_exclude/**"]
assert loaded.max_file_size == 50 * 1024


def test_default_project_settings_with_user_settings() -> None:
user_settings = UserSettings(
embedding=EmbeddingSettings(provider="litellm", model="m"),
include_patterns=["**/*.go"],
exclude_patterns=["**/vendor/**"],
max_file_size=100 * 1024,
)
ps = default_project_settings(user_settings=user_settings)
assert ps.include_patterns == ["**/*.go"]
assert ps.exclude_patterns == ["**/vendor/**"]
assert ps.max_file_size == 100 * 1024
Loading