diff --git a/src/cocoindex_code/client.py b/src/cocoindex_code/client.py index bb36558..bb6f5d7 100644 --- a/src/cocoindex_code/client.py +++ b/src/cocoindex_code/client.py @@ -156,6 +156,15 @@ def _send(self, req: Request) -> Response: def is_daemon_running() -> bool: """Check if the daemon is running.""" + if sys.platform == "win32": + # os.path.exists is unreliable for Windows named pipes; + # try connecting instead. + try: + conn = Client(daemon_socket_path(), family=_connection_family()) + conn.close() + return True + except (ConnectionRefusedError, OSError): + return False return os.path.exists(daemon_socket_path()) @@ -220,11 +229,14 @@ def stop_daemon() -> None: return # Clean exit # Step 3: if still running, try SIGTERM + pid: int | None = None if pid_path.exists(): try: pid = int(pid_path.read_text().strip()) if pid != os.getpid(): os.kill(pid, signal.SIGTERM) + else: + pid = None except (ValueError, ProcessLookupError, PermissionError): pass @@ -240,9 +252,22 @@ def stop_daemon() -> None: pid = int(pid_path.read_text().strip()) if pid != os.getpid(): os.kill(pid, signal.SIGKILL) + else: + pid = None except (ValueError, ProcessLookupError, PermissionError): pass + # Step 4b: on Windows, wait for the process to fully exit after TerminateProcess + # so that named pipe handles are released before starting a new daemon. + if sys.platform == "win32" and pid is not None: + deadline = time.monotonic() + 3.0 + while time.monotonic() < deadline: + try: + os.kill(pid, 0) # Check if process still exists + time.sleep(0.1) + except (ProcessLookupError, PermissionError, OSError): + break # Process has exited + # Step 5: clean up stale files if sys.platform != "win32": sock = daemon_socket_path() @@ -256,13 +281,24 @@ def stop_daemon() -> None: pass -def _wait_for_daemon(timeout: float = 10.0) -> None: +def _wait_for_daemon(timeout: float = 30.0) -> None: """Wait for the daemon socket/pipe to become available.""" deadline = time.monotonic() + timeout + sock_path = daemon_socket_path() while time.monotonic() < deadline: - if os.path.exists(daemon_socket_path()): - return - time.sleep(0.1) + if sys.platform == "win32": + # os.path.exists is unreliable for Windows named pipes; + # try an actual connection to verify the daemon is listening. + try: + conn = Client(sock_path, family=_connection_family()) + conn.close() + return + except (ConnectionRefusedError, OSError): + pass + else: + if os.path.exists(sock_path): + return + time.sleep(0.2) raise TimeoutError("Daemon did not start in time") diff --git a/src/cocoindex_code/settings.py b/src/cocoindex_code/settings.py index b3a278d..a403dc2 100644 --- a/src/cocoindex_code/settings.py +++ b/src/cocoindex_code/settings.py @@ -66,14 +66,14 @@ @dataclass class EmbeddingSettings: - provider: str = "sentence-transformers" - model: str = "sentence-transformers/all-MiniLM-L6-v2" + model: str + provider: str = "litellm" device: str | None = None @dataclass class UserSettings: - embedding: EmbeddingSettings = field(default_factory=EmbeddingSettings) + embedding: EmbeddingSettings envs: dict[str, str] = field(default_factory=dict) @@ -99,7 +99,12 @@ class ProjectSettings: def default_user_settings() -> UserSettings: - return UserSettings() + return UserSettings( + embedding=EmbeddingSettings( + provider="sentence-transformers", + model="sentence-transformers/all-MiniLM-L6-v2", + ) + ) def default_project_settings() -> ProjectSettings: @@ -211,27 +216,29 @@ def load_gitignore_spec(project_root: Path) -> GitIgnoreSpec | None: def _user_settings_to_dict(settings: UserSettings) -> dict[str, Any]: d: dict[str, Any] = {} - emb: dict[str, Any] = {} - if settings.embedding.provider != "sentence-transformers": - emb["provider"] = settings.embedding.provider - if settings.embedding.model != "sentence-transformers/all-MiniLM-L6-v2": - emb["model"] = settings.embedding.model + emb: dict[str, Any] = { + "provider": settings.embedding.provider, + "model": settings.embedding.model, + } if settings.embedding.device is not None: emb["device"] = settings.embedding.device - if emb: - d["embedding"] = emb + d["embedding"] = emb if settings.envs: d["envs"] = dict(settings.envs) return d def _user_settings_from_dict(d: dict[str, Any]) -> UserSettings: - emb_dict = d.get("embedding", {}) - embedding = EmbeddingSettings( - provider=emb_dict.get("provider", "sentence-transformers"), - model=emb_dict.get("model", "sentence-transformers/all-MiniLM-L6-v2"), - device=emb_dict.get("device"), - ) + emb_dict = d.get("embedding") + if not emb_dict or "model" not in emb_dict: + raise ValueError("global_settings.yml must contain 'embedding' with at least 'model' field") + # Only pass keys that are present; provider uses dataclass default ("litellm") if omitted + emb_kwargs: dict[str, Any] = {"model": emb_dict["model"]} + if "provider" in emb_dict: + emb_kwargs["provider"] = emb_dict["provider"] + if "device" in emb_dict: + emb_kwargs["device"] = emb_dict["device"] + embedding = EmbeddingSettings(**emb_kwargs) envs = d.get("envs", {}) return UserSettings(embedding=embedding, envs=envs) @@ -265,14 +272,17 @@ def _project_settings_from_dict(d: dict[str, Any]) -> ProjectSettings: def load_user_settings() -> UserSettings: - """Read ``~/.cocoindex_code/settings.yml``, return defaults if missing.""" + """Read ``~/.cocoindex_code/global_settings.yml``. + + Raises ``FileNotFoundError`` if missing, ``ValueError`` if incomplete. + """ path = user_settings_path() if not path.is_file(): - return default_user_settings() + raise FileNotFoundError(f"User settings not found: {path}") with open(path) as f: data = _yaml.safe_load(f) if not data: - return default_user_settings() + raise ValueError(f"User settings file is empty: {path}") return _user_settings_from_dict(data) diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 73fcdac..e376af8 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -54,11 +54,12 @@ def calculate_fibonacci(n: int) -> int: def daemon_sock() -> Iterator[str]: """Start a daemon once per session and return the socket path.""" import cocoindex_code.daemon as dm - from cocoindex_code.settings import EmbeddingSettings from cocoindex_code.shared import create_embedder from cocoindex_code.shared import embedder as shared_emb - emb = shared_emb if shared_emb is not None else create_embedder(EmbeddingSettings()) + emb = ( + shared_emb if shared_emb is not None else create_embedder(default_user_settings().embedding) + ) # Use a short path to stay within AF_UNIX limit user_dir = Path(tempfile.mkdtemp(prefix="ccc_d_")) diff --git a/tests/test_settings.py b/tests/test_settings.py index 9891ee5..fd56f31 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -86,12 +86,47 @@ def test_save_and_load_project_settings(tmp_path: Path) -> None: @pytest.mark.usefixtures("_patch_user_dir") -def test_load_user_settings_missing_file_returns_defaults() -> None: - loaded = load_user_settings() - expected = default_user_settings() - assert loaded.embedding.provider == expected.embedding.provider - assert loaded.embedding.model == expected.embedding.model - assert loaded.envs == expected.envs +def test_load_user_settings_missing_file_raises() -> None: + with pytest.raises(FileNotFoundError): + load_user_settings() + + +@pytest.mark.usefixtures("_patch_user_dir") +def test_load_user_settings_empty_file_raises(tmp_path: Path) -> None: + path = tmp_path / ".cocoindex_code" / "global_settings.yml" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{}\n") + with pytest.raises(ValueError): + load_user_settings() + + +@pytest.mark.usefixtures("_patch_user_dir") +def test_load_user_settings_missing_model_raises(tmp_path: Path) -> None: + path = tmp_path / ".cocoindex_code" / "global_settings.yml" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("embedding:\n provider: litellm\n") + with pytest.raises(ValueError): + load_user_settings() + + +@pytest.mark.usefixtures("_patch_user_dir") +def test_from_dict_missing_provider_defaults_to_litellm() -> None: + from cocoindex_code.settings import _user_settings_from_dict + + settings = _user_settings_from_dict({"embedding": {"model": "some/model"}}) + assert settings.embedding.provider == "litellm" + assert settings.embedding.model == "some/model" + + +@pytest.mark.usefixtures("_patch_user_dir") +def test_save_default_settings_writes_explicit_embedding() -> None: + from cocoindex_code.settings import user_settings_path + + save_user_settings(default_user_settings()) + content = user_settings_path().read_text() + assert "provider:" in content + assert "model:" in content + assert "sentence-transformers" in content def test_load_project_settings_missing_file_raises(tmp_path: Path) -> None: