From 3540a646dbec848601f5f07aed6752135c9cfdb2 Mon Sep 17 00:00:00 2001 From: gaojunran Date: Sun, 26 Jul 2026 05:28:35 +0000 Subject: [PATCH] feat: support {env:VAR} interpolation in settings Allow any string value in global_settings.yml and project settings.yml to reference environment variables via {env:VAR_NAME} placeholders. Placeholders are expanded at load time, before dataclass construction, so every config field (model name, API keys, base URLs, patterns, etc.) can use them. Unset variables are replaced with empty strings. This lets users keep secrets out of config files without relying solely on shell environment inheritance. --- README.md | 12 +++++ src/cocoindex_code/settings.py | 33 +++++++++++- tests/test_settings.py | 94 ++++++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f3a6b4d..5218996 100644 --- a/README.md +++ b/README.md @@ -522,6 +522,18 @@ daemon: > **Note:** The daemon inherits your shell environment. If an API key (e.g. `OPENAI_API_KEY`) is already set as an environment variable, you don't need to duplicate it in `envs`. The `envs` field is only for values that aren't in your environment. +> **Environment variable interpolation:** Any string value in both global and project settings supports `{env:VAR_NAME}` placeholders, which are replaced with the corresponding environment variable at load time. This lets you keep secrets out of config files without relying solely on shell inheritance. For example: +> +> ```yaml +> embedding: +> model: openai/your-model-name +> envs: +> OPENAI_BASE_URL: "https://{env:MY_LLM_HOST}/v1" +> OPENAI_API_KEY: "{env:MY_OPENAI_API_KEY}" +> ``` +> +> Placeholders can appear anywhere in a string and work on every config field. If the referenced variable is unset, it's replaced with an empty string. + > **Idle timeout:** the background daemon holds the embedding model in RAM, so it exits after `daemon.idle_timeout_minutes` without client activity and is restarted automatically on your next `ccc` command or MCP search. A live MCP session sends periodic heartbeats, so the daemon never idles out while your coding agent is connected. Set `0` to keep the daemon running forever. > **Custom location:** set `COCOINDEX_CODE_DIR` to place `global_settings.yml` somewhere other than `~/.cocoindex_code/` — useful if you want the file to live alongside your projects (e.g. on a synced folder). diff --git a/src/cocoindex_code/settings.py b/src/cocoindex_code/settings.py index e228839..8550a13 100644 --- a/src/cocoindex_code/settings.py +++ b/src/cocoindex_code/settings.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import re from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any @@ -446,6 +447,34 @@ def _user_settings_to_dict(settings: UserSettings) -> dict[str, Any]: return d +_ENV_PLACEHOLDER_RE = re.compile(r"\{env:([^}]+)\}") + + +def _expand_env_placeholders(value: Any) -> Any: + """Recursively replace ``{env:VAR}`` in strings with ``os.environ[VAR]``. + + Works on any nested structure of dicts, lists, and strings, so every + config value — model name, API key, base URL, etc. — can reference an + environment variable. If the variable is unset the placeholder is + replaced with an empty string. Non-string values are returned unchanged. + + Example:: + + envs: + OPENAI_API_KEY: "{env:OPENAI_API_KEY}" + OPENAI_BASE_URL: "https://{env:MY_HOST}/v1" + """ + if isinstance(value, str): + return _ENV_PLACEHOLDER_RE.sub( + lambda m: os.environ.get(m.group(1), ""), value + ) + if isinstance(value, dict): + return {k: _expand_env_placeholders(v) for k, v in value.items()} + if isinstance(value, list): + return [_expand_env_placeholders(v) for v in value] + return value + + def _user_settings_from_dict(d: dict[str, Any]) -> UserSettings: emb_dict = d.get("embedding") if not emb_dict or "model" not in emb_dict: @@ -521,7 +550,7 @@ def load_user_settings() -> UserSettings: data = _yaml.safe_load(f) if not data: raise ValueError("File is empty") - return _user_settings_from_dict(data) + return _user_settings_from_dict(_expand_env_placeholders(data)) except Exception as e: raise type(e)(f"Error loading {path}: {e}") from e @@ -620,7 +649,7 @@ def load_project_settings(project_root: Path) -> ProjectSettings: data = _yaml.safe_load(f) if not data: return default_project_settings() - return _project_settings_from_dict(data) + return _project_settings_from_dict(_expand_env_placeholders(data)) except Exception as e: raise type(e)(f"Error loading {path}: {e}") from e diff --git a/tests/test_settings.py b/tests/test_settings.py index f9be64b..d2bbe36 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -684,3 +684,97 @@ def test_save_initial_writes_comment_template_for_unknown_litellm() -> None: # `dimensions` is intentionally NOT in the litellm template — it must be # the same on both sides, so we don't expose it as a per-side knob. assert "dimensions" not in content + + +# --------------------------------------------------------------------------- +# Environment variable interpolation ({env:VAR}) +# --------------------------------------------------------------------------- + + +@pytest.mark.usefixtures("_patch_user_dir") +def test_env_interpolation_in_envs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """``{env:VAR}`` in envs values is replaced at load time.""" + monkeypatch.setenv("MY_OPENAI_KEY", "sk-secret") + path = tmp_path / ".cocoindex_code" / "global_settings.yml" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "embedding:\n model: text-embedding-3-small\n" + "envs:\n OPENAI_API_KEY: '{env:MY_OPENAI_KEY}'\n" + ) + loaded = load_user_settings() + assert loaded.envs["OPENAI_API_KEY"] == "sk-secret" + + +@pytest.mark.usefixtures("_patch_user_dir") +def test_env_interpolation_partial_string( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """``{env:VAR}`` can be embedded inside a larger string.""" + monkeypatch.setenv("MY_HOST", "my-llm-server.local") + path = tmp_path / ".cocoindex_code" / "global_settings.yml" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "embedding:\n model: openai/my-model\n" + "envs:\n OPENAI_BASE_URL: 'https://{env:MY_HOST}/v1'\n" + ) + loaded = load_user_settings() + assert loaded.envs["OPENAI_BASE_URL"] == "https://my-llm-server.local/v1" + + +@pytest.mark.usefixtures("_patch_user_dir") +def test_env_interpolation_in_model_name( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Interpolation works on any config field, not just envs.""" + monkeypatch.setenv("EMB_MODEL", "text-embedding-3-small") + path = tmp_path / ".cocoindex_code" / "global_settings.yml" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("embedding:\n model: '{env:EMB_MODEL}'\n") + loaded = load_user_settings() + assert loaded.embedding.model == "text-embedding-3-small" + + +@pytest.mark.usefixtures("_patch_user_dir") +def test_env_interpolation_unset_var_becomes_empty( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Unset variables are replaced with an empty string (no error).""" + monkeypatch.delenv("DEFINITELY_UNSET_VAR", raising=False) + path = tmp_path / ".cocoindex_code" / "global_settings.yml" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "embedding:\n model: openai/my-model\n" + "envs:\n OPENAI_BASE_URL: 'https://{env:DEFINITELY_UNSET_VAR}/v1'\n" + ) + loaded = load_user_settings() + assert loaded.envs["OPENAI_BASE_URL"] == "https:///v1" + + +@pytest.mark.usefixtures("_patch_user_dir") +def test_env_interpolation_does_not_mutate_saved_settings( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Saving settings after loading with interpolation writes expanded values.""" + monkeypatch.setenv("MY_KEY", "expanded-value") + settings = UserSettings( + embedding=EmbeddingSettings(model="text-embedding-3-small"), + envs={"OPENAI_API_KEY": "{env:MY_KEY}"}, + ) + save_user_settings(settings) + loaded = load_user_settings() + assert loaded.envs["OPENAI_API_KEY"] == "expanded-value" + + +def test_env_interpolation_in_project_settings( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Project settings also support ``{env:VAR}`` interpolation.""" + monkeypatch.setenv("MY_LANG", "php") + settings = ProjectSettings( + include_patterns=["**/*.py"], + exclude_patterns=[], + language_overrides=[LanguageOverride(ext="inc", lang="{env:MY_LANG}")], + ) + save_project_settings(tmp_path, settings) + loaded = load_project_settings(tmp_path) + assert loaded.language_overrides[0].lang == "php"