v2.18.0 (#843)
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Cyprien <courtot.c@gmail.com> Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com> Co-authored-by: Jean Burellier <sheplu@users.noreply.github.com> Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Nelson PROIA <144663685+Nelson-PROIA@users.noreply.github.com> Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Quentin <quentin.torroba@mistral.ai> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: josephine-delas <57808586+josephine-delas@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
725d3a56ce
commit
e607ccbb00
242 changed files with 7372 additions and 1974 deletions
|
|
@ -147,11 +147,7 @@ def _build_managers(config):
|
|||
@pytest.mark.asyncio
|
||||
async def test_graduated_experiment_with_deleted_variant_file_falls_back() -> None:
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="cli",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
include_model_info=False,
|
||||
include_commit_signature=False,
|
||||
system_prompt_id="cli", include_model_info=False, include_commit_signature=False
|
||||
)
|
||||
response = _response_forcing("removed_after_graduation_2025_07")
|
||||
manager = ExperimentManager(client=_StubClient(None))
|
||||
|
|
|
|||
|
|
@ -47,11 +47,7 @@ def _build_managers(config):
|
|||
@pytest.mark.asyncio
|
||||
async def test_system_prompt_uses_assigned_variant() -> None:
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="cli",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
include_model_info=False,
|
||||
include_commit_signature=False,
|
||||
system_prompt_id="cli", include_model_info=False, include_commit_signature=False
|
||||
)
|
||||
response = EvalResponse.model_validate({
|
||||
"features": {
|
||||
|
|
@ -78,11 +74,7 @@ async def test_system_prompt_uses_assigned_variant() -> None:
|
|||
@pytest.mark.asyncio
|
||||
async def test_system_prompt_falls_back_to_default_when_variant_unknown() -> None:
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="cli",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
include_model_info=False,
|
||||
include_commit_signature=False,
|
||||
system_prompt_id="cli", include_model_info=False, include_commit_signature=False
|
||||
)
|
||||
response = EvalResponse.model_validate({
|
||||
"features": {
|
||||
|
|
@ -109,11 +101,7 @@ async def test_system_prompt_falls_back_to_default_when_variant_unknown() -> Non
|
|||
|
||||
def test_system_prompt_uses_default_when_no_manager() -> None:
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="cli",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
include_model_info=False,
|
||||
include_commit_signature=False,
|
||||
system_prompt_id="cli", include_model_info=False, include_commit_signature=False
|
||||
)
|
||||
tool_manager, skill_manager, agent_manager = _build_managers(config)
|
||||
prompt = get_universal_system_prompt(
|
||||
|
|
@ -125,8 +113,6 @@ def test_system_prompt_uses_default_when_no_manager() -> None:
|
|||
def test_system_prompt_honors_user_config_when_manager_uninitialized() -> None:
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="lean",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
include_model_info=False,
|
||||
include_commit_signature=False,
|
||||
)
|
||||
|
|
@ -143,8 +129,6 @@ def test_system_prompt_honors_user_config_when_manager_uninitialized() -> None:
|
|||
async def test_system_prompt_honors_user_config_when_no_remote_assignment() -> None:
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="lean",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
include_model_info=False,
|
||||
include_commit_signature=False,
|
||||
)
|
||||
|
|
@ -169,8 +153,6 @@ async def test_system_prompt_honors_user_config_when_no_remote_assignment() -> N
|
|||
async def test_user_config_overrides_assigned_experiment_variant() -> None:
|
||||
config = build_test_vibe_config(
|
||||
system_prompt_id="lean",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
include_model_info=False,
|
||||
include_commit_signature=False,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -31,9 +31,7 @@ class TestAgentProfile:
|
|||
class TestAgentManager:
|
||||
@pytest.fixture
|
||||
def manager(self) -> AgentManager:
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False, include_prompt_detail=False
|
||||
)
|
||||
config = build_test_vibe_config()
|
||||
return AgentManager(lambda: config)
|
||||
|
||||
def test_get_subagents_returns_only_subagents(self, manager: AgentManager) -> None:
|
||||
|
|
@ -81,17 +79,13 @@ class TestAgentManager:
|
|||
|
||||
def test_initial_agent_rejects_subagent(self) -> None:
|
||||
"""Test that creating AgentManager with a subagent as initial_agent raises."""
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False, include_prompt_detail=False
|
||||
)
|
||||
config = build_test_vibe_config()
|
||||
with pytest.raises(ValueError, match="cannot be used as the primary agent"):
|
||||
AgentManager(lambda: config, initial_agent="explore")
|
||||
|
||||
def test_initial_agent_accepts_subagent_when_allowed(self) -> None:
|
||||
"""Test that allow_subagent=True permits subagent as initial_agent."""
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False, include_prompt_detail=False
|
||||
)
|
||||
config = build_test_vibe_config()
|
||||
manager = AgentManager(
|
||||
lambda: config, initial_agent="explore", allow_subagent=True
|
||||
)
|
||||
|
|
@ -99,18 +93,12 @@ class TestAgentManager:
|
|||
|
||||
def test_initial_agent_accepts_agent_type(self) -> None:
|
||||
"""Test that creating AgentManager with an agent-type agent works."""
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False, include_prompt_detail=False
|
||||
)
|
||||
config = build_test_vibe_config()
|
||||
manager = AgentManager(lambda: config, initial_agent="plan")
|
||||
assert manager.active_profile.name == "plan"
|
||||
|
||||
def test_initial_agent_raises_when_agent_is_disabled(self) -> None:
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
disabled_agents=["plan"],
|
||||
)
|
||||
config = build_test_vibe_config(disabled_agents=["plan"])
|
||||
with pytest.raises(ValueError, match="disabled_agents") as exc_info:
|
||||
AgentManager(lambda: config, initial_agent="plan")
|
||||
message = str(exc_info.value)
|
||||
|
|
@ -120,11 +108,7 @@ class TestAgentManager:
|
|||
def test_explicit_agent_excluded_by_enabled_agents_does_not_blame_default(
|
||||
self,
|
||||
) -> None:
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
enabled_agents=["default"],
|
||||
)
|
||||
config = build_test_vibe_config(enabled_agents=["default"])
|
||||
with pytest.raises(ValueError, match="enabled_agents") as exc_info:
|
||||
AgentManager(lambda: config, initial_agent="plan")
|
||||
message = str(exc_info.value)
|
||||
|
|
@ -132,20 +116,14 @@ class TestAgentManager:
|
|||
assert message.startswith("Agent 'plan'")
|
||||
|
||||
def test_initial_agent_raises_when_agent_does_not_exist(self) -> None:
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False, include_prompt_detail=False
|
||||
)
|
||||
config = build_test_vibe_config()
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
AgentManager(lambda: config, initial_agent="nonexistent-agent")
|
||||
|
||||
def test_default_agent_excluded_by_enabled_agents_raises_config_contradiction(
|
||||
self,
|
||||
) -> None:
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
enabled_agents=["plan"],
|
||||
)
|
||||
config = build_test_vibe_config(enabled_agents=["plan"])
|
||||
with pytest.raises(ValueError, match="enabled_agents") as exc_info:
|
||||
AgentManager(lambda: config)
|
||||
message = str(exc_info.value)
|
||||
|
|
@ -155,11 +133,7 @@ class TestAgentManager:
|
|||
def test_default_agent_excluded_by_disabled_agents_raises_config_contradiction(
|
||||
self,
|
||||
) -> None:
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
disabled_agents=["default"],
|
||||
)
|
||||
config = build_test_vibe_config(disabled_agents=["default"])
|
||||
with pytest.raises(ValueError, match="disabled_agents") as exc_info:
|
||||
AgentManager(lambda: config)
|
||||
assert "default_agent" in str(exc_info.value)
|
||||
|
|
@ -168,10 +142,7 @@ class TestAgentManager:
|
|||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
enabled_agents=["plan"],
|
||||
disabled_agents=["plan"],
|
||||
enabled_agents=["plan"], disabled_agents=["plan"]
|
||||
)
|
||||
with caplog.at_level("WARNING"):
|
||||
manager = AgentManager(lambda: config, initial_agent="plan")
|
||||
|
|
@ -181,11 +152,7 @@ class TestAgentManager:
|
|||
def test_install_required_agent_reports_install_not_disabled_agents(self) -> None:
|
||||
# 'lean' is install_required and enabled but not installed: the message
|
||||
# must point to installation, not blame disabled_agents.
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
enabled_agents=["lean"],
|
||||
)
|
||||
config = build_test_vibe_config(enabled_agents=["lean"])
|
||||
with pytest.raises(ValueError, match="requires installation") as exc_info:
|
||||
AgentManager(lambda: config, initial_agent="lean")
|
||||
assert "disabled_agents" not in str(exc_info.value)
|
||||
|
|
|
|||
93
tests/core/test_config_bus.py
Normal file
93
tests/core/test_config_bus.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.config.event_bus import EventBus
|
||||
from vibe.core.config.types import ConfigChangeEvent
|
||||
|
||||
|
||||
def _make_event(changed: set[str]) -> ConfigChangeEvent:
|
||||
return ConfigChangeEvent(
|
||||
changed_keys=frozenset(changed), before={}, after={}, reason=""
|
||||
)
|
||||
|
||||
|
||||
def test_wildcard_subscriber_fires_on_every_event() -> None:
|
||||
bus = EventBus()
|
||||
received: list[ConfigChangeEvent] = []
|
||||
bus.subscribe(received.append)
|
||||
|
||||
event = _make_event({"value"})
|
||||
bus.publish(event)
|
||||
|
||||
assert received == [event]
|
||||
|
||||
|
||||
def test_keyed_subscriber_filters_by_key() -> None:
|
||||
bus = EventBus()
|
||||
received: list[ConfigChangeEvent] = []
|
||||
bus.subscribe(received.append, keys={"value"})
|
||||
|
||||
bus.publish(_make_event({"other"}))
|
||||
assert received == []
|
||||
|
||||
bus.publish(_make_event({"value", "other"}))
|
||||
assert len(received) == 1
|
||||
|
||||
|
||||
def test_ancestor_descendant_keys_match_bidirectionally() -> None:
|
||||
bus = EventBus()
|
||||
received: list[ConfigChangeEvent] = []
|
||||
bus.subscribe(received.append, keys={"models"})
|
||||
bus.subscribe(received.append, keys={"models/models"})
|
||||
|
||||
bus.publish(_make_event({"models/models"}))
|
||||
bus.publish(_make_event({"models"}))
|
||||
|
||||
assert len(received) == 4
|
||||
|
||||
|
||||
def test_partial_segment_is_not_a_prefix_match() -> None:
|
||||
bus = EventBus()
|
||||
received: list[ConfigChangeEvent] = []
|
||||
bus.subscribe(received.append, keys={"model"})
|
||||
|
||||
bus.publish(_make_event({"models"}))
|
||||
|
||||
assert received == []
|
||||
|
||||
|
||||
def test_sibling_keys_do_not_match() -> None:
|
||||
bus = EventBus()
|
||||
received: list[ConfigChangeEvent] = []
|
||||
bus.subscribe(received.append, keys={"model/other1"})
|
||||
|
||||
bus.publish(_make_event({"model/other2"}))
|
||||
|
||||
assert received == []
|
||||
|
||||
|
||||
def test_unsubscribe_stops_delivery() -> None:
|
||||
bus = EventBus()
|
||||
received: list[ConfigChangeEvent] = []
|
||||
unsubscribe = bus.subscribe(received.append)
|
||||
unsubscribe()
|
||||
|
||||
bus.publish(_make_event({"value"}))
|
||||
|
||||
assert received == []
|
||||
|
||||
|
||||
def test_unsubscribe_during_publish_is_safe() -> None:
|
||||
bus = EventBus()
|
||||
calls: list[str] = []
|
||||
|
||||
def first(_: ConfigChangeEvent) -> None:
|
||||
calls.append("first")
|
||||
unsubscribe_second()
|
||||
|
||||
bus.subscribe(first)
|
||||
unsubscribe_second = bus.subscribe(lambda _: calls.append("second"))
|
||||
|
||||
bus.publish(_make_event({"value"}))
|
||||
bus.publish(_make_event({"value"}))
|
||||
|
||||
assert calls == ["first", "second", "first"]
|
||||
|
|
@ -1,15 +1,41 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import ValidationError
|
||||
from pydantic import Field, ValidationError
|
||||
import pytest
|
||||
|
||||
from vibe.core.config.layer import ConfigLayer, RawConfig
|
||||
from vibe.core.config.orchestrator import ConfigOrchestrator
|
||||
from vibe.core.config.patch import ConfigPatch
|
||||
from vibe.core.config.schema import ConfigSchema, WithReplaceMerge
|
||||
from vibe.core.config.types import LayerConfigSnapshot
|
||||
from vibe.core.config.event_bus import EventBus
|
||||
from vibe.core.config.layer import (
|
||||
ConfigLayer,
|
||||
ConfigPatchApplicationError,
|
||||
LayerImplementationError,
|
||||
LayerNotLoadedError,
|
||||
RawConfig,
|
||||
)
|
||||
from vibe.core.config.layers.environment import EnvironmentLayer
|
||||
from vibe.core.config.layers.user import UserConfigLayer
|
||||
from vibe.core.config.orchestrator import ConfigOrchestrator, ConfigPatchValidationError
|
||||
from vibe.core.config.patch import (
|
||||
AddOperationPatch,
|
||||
RemoveOperationPatch,
|
||||
ReplaceOperationPatch,
|
||||
)
|
||||
from vibe.core.config.schema import (
|
||||
ConfigFragment,
|
||||
ConfigSchema,
|
||||
WithConcatMerge,
|
||||
WithReplaceMerge,
|
||||
)
|
||||
from vibe.core.config.types import (
|
||||
ConcurrencyConflictError,
|
||||
ConfigChangeEvent,
|
||||
LayerConfigSnapshot,
|
||||
)
|
||||
|
||||
|
||||
class FakeLayer(ConfigLayer[RawConfig]):
|
||||
|
|
@ -27,15 +53,115 @@ class FakeLayer(ConfigLayer[RawConfig]):
|
|||
raise NotImplementedError
|
||||
|
||||
|
||||
class NormalizingWritableLayer(FakeLayer):
|
||||
async def _save_to_store(self, next_config: RawConfig) -> str:
|
||||
value = next_config.model_dump()["value"]
|
||||
self._data = {"value": f"{value}-normalized"}
|
||||
return "write-fp"
|
||||
|
||||
|
||||
class FieldWritableLayer(FakeLayer):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
field_name: str,
|
||||
data: dict[str, Any],
|
||||
barrier: ParallelSaveBarrier | None = None,
|
||||
) -> None:
|
||||
super().__init__(name=name, data=data)
|
||||
self._field_name = field_name
|
||||
self._barrier = barrier
|
||||
|
||||
async def _save_to_store(self, next_config: RawConfig) -> str:
|
||||
if self._barrier is not None:
|
||||
await self._barrier.wait(self.name)
|
||||
|
||||
value = next_config.model_dump()[self._field_name]
|
||||
self._data = {self._field_name: value}
|
||||
return "write-fp"
|
||||
|
||||
|
||||
class RawWritableLayer(FakeLayer):
|
||||
async def _save_to_store(self, next_config: RawConfig) -> str:
|
||||
self._data = next_config.model_dump()
|
||||
return "write-fp"
|
||||
|
||||
|
||||
class FailingSaveLayer(FakeLayer):
|
||||
async def _save_to_store(self, _next_config: RawConfig) -> str:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
|
||||
class ApplyErrorLayer(FakeLayer):
|
||||
def __init__(self, *, name: str, data: dict[str, Any], error: Exception) -> None:
|
||||
super().__init__(name=name, data=data)
|
||||
self._error = error
|
||||
|
||||
async def apply(self, *args: Any, **kwargs: Any) -> None:
|
||||
raise self._error
|
||||
|
||||
|
||||
class ParallelSaveBarrier:
|
||||
def __init__(self, expected_starts: int) -> None:
|
||||
self.expected_starts = expected_starts
|
||||
self.started_layers: list[str] = []
|
||||
self.all_started = asyncio.Event()
|
||||
|
||||
async def wait(self, layer_name: str) -> None:
|
||||
self.started_layers.append(layer_name)
|
||||
if len(self.started_layers) == self.expected_starts:
|
||||
self.all_started.set()
|
||||
|
||||
await asyncio.wait_for(self.all_started.wait(), timeout=0.1)
|
||||
|
||||
|
||||
class SimpleSchema(ConfigSchema):
|
||||
value: Annotated[str, WithReplaceMerge()] = "default"
|
||||
|
||||
|
||||
class MultiValueSchema(ConfigSchema):
|
||||
first: Annotated[str, WithReplaceMerge()] = "default-first"
|
||||
second: Annotated[str, WithReplaceMerge()] = "default-second"
|
||||
|
||||
|
||||
class ToolsFragment(ConfigFragment):
|
||||
enabled_tools: Annotated[list[str], WithConcatMerge()] = Field(default_factory=list)
|
||||
disabled_tools: Annotated[list[str], WithConcatMerge()] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
deprecated_setting: Annotated[bool, WithReplaceMerge()] = False
|
||||
|
||||
|
||||
class ToolSchema(ConfigSchema):
|
||||
active_model: Annotated[str, WithReplaceMerge()] = "default-model"
|
||||
tools: ToolsFragment = Field(default_factory=ToolsFragment)
|
||||
|
||||
|
||||
class RoutingSchema(ConfigSchema):
|
||||
active_model: Annotated[str, WithReplaceMerge()] = "default-model"
|
||||
default_agent: Annotated[str, WithReplaceMerge()] = "default-agent"
|
||||
|
||||
|
||||
class RequiredPairSchema(ConfigSchema):
|
||||
first: Annotated[str, WithReplaceMerge()]
|
||||
second: Annotated[str, WithReplaceMerge()]
|
||||
|
||||
|
||||
def assert_single_failure[E: BaseException](
|
||||
result: Sequence[BaseException], expected_type: type[E]
|
||||
) -> E:
|
||||
assert len(result) == 1
|
||||
failure = result[0]
|
||||
assert isinstance(failure, expected_type)
|
||||
return failure
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_builds_config() -> None:
|
||||
layer = FakeLayer(name="test", data={"value": "hello"})
|
||||
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer])
|
||||
assert orch.config.value == "hello"
|
||||
assert orch.config.model_dump() == {"value": "hello"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -48,7 +174,7 @@ async def test_get_layer_returns_named_layer() -> None:
|
|||
@pytest.mark.asyncio
|
||||
async def test_get_layer_unknown_raises() -> None:
|
||||
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[])
|
||||
with pytest.raises(KeyError, match="unknown"):
|
||||
with pytest.raises(KeyError, match="No layer named 'unknown'"):
|
||||
orch.get_layer("unknown")
|
||||
|
||||
|
||||
|
|
@ -78,14 +204,333 @@ async def test_origin_of_missing_key_returns_none() -> None:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_raises_not_implemented() -> None:
|
||||
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[])
|
||||
with pytest.raises(NotImplementedError, match="M2"):
|
||||
await orch.apply_patch(ConfigPatch(fingerprint="fp-1"))
|
||||
async def test_apply_patch_empty_operations_is_noop() -> None:
|
||||
layer = FakeLayer(name="test", data={"value": "hello"})
|
||||
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer])
|
||||
|
||||
result = await orch.apply_patch([], reason="no-op")
|
||||
|
||||
assert result == []
|
||||
assert orch.config.value == "hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_raises_not_implemented() -> None:
|
||||
async def test_apply_patch_rejects_invalid_schema_result_before_routing() -> None:
|
||||
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[])
|
||||
with pytest.raises(NotImplementedError, match="M3"):
|
||||
await orch.subscribe(lambda: None)
|
||||
with pytest.raises(ConfigPatchValidationError) as exc_info:
|
||||
await orch.apply_patch(
|
||||
[ReplaceOperationPatch(path="/value", value={"invalid": "shape"})],
|
||||
reason="test invalid patch",
|
||||
)
|
||||
|
||||
assert exc_info.value.args == (
|
||||
"Config patch failed preflight validation against the merged config; fix the patch payload and retry",
|
||||
)
|
||||
assert isinstance(exc_info.value.__cause__, ValidationError)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_returns_failure_when_default_fallback_layer_is_missing() -> (
|
||||
None
|
||||
):
|
||||
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[])
|
||||
|
||||
result = await orch.apply_patch(
|
||||
[ReplaceOperationPatch(path="/value", value="updated")], reason="test update"
|
||||
)
|
||||
|
||||
failure = assert_single_failure(result, KeyError)
|
||||
assert str(failure) == f'"No layer named {UserConfigLayer.LAYER_NAME!r}"'
|
||||
assert orch.config.value == "default"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_unknown_explicit_target_returns_failure() -> None:
|
||||
layer = NormalizingWritableLayer(name=UserConfigLayer.LAYER_NAME, data={})
|
||||
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer])
|
||||
|
||||
result = await orch.apply_patch(
|
||||
[
|
||||
AddOperationPatch(
|
||||
path="/value", value="updated", target_layer_name="missing-layer"
|
||||
)
|
||||
],
|
||||
reason="test update",
|
||||
)
|
||||
|
||||
failure = assert_single_failure(result, KeyError)
|
||||
assert str(failure) == "\"No layer named 'missing-layer'\""
|
||||
assert orch.config.value == "default"
|
||||
assert layer._data == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_falls_back_to_default_user_layer_and_reloads_config() -> (
|
||||
None
|
||||
):
|
||||
layer = NormalizingWritableLayer(name=UserConfigLayer.LAYER_NAME, data={})
|
||||
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer])
|
||||
|
||||
result = await orch.apply_patch(
|
||||
[AddOperationPatch(path="/value", value="updated")], reason="test update"
|
||||
)
|
||||
|
||||
assert result == []
|
||||
assert orch.config.value == "updated-normalized"
|
||||
assert layer._data == {"value": "updated-normalized"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_returns_layer_save_error_after_other_layer_commits() -> None:
|
||||
first_layer = FieldWritableLayer(
|
||||
name="first-layer", field_name="first", data={"first": "one"}
|
||||
)
|
||||
second_layer = FailingSaveLayer(name="second-layer", data={"second": "two"})
|
||||
orch = await ConfigOrchestrator.create(
|
||||
schema=MultiValueSchema, layers=[first_layer, second_layer]
|
||||
)
|
||||
|
||||
result = await orch.apply_patch(
|
||||
[
|
||||
ReplaceOperationPatch(
|
||||
path="/first", value="updated-one", target_layer_name="first-layer"
|
||||
),
|
||||
ReplaceOperationPatch(
|
||||
path="/second", value="updated-two", target_layer_name="second-layer"
|
||||
),
|
||||
],
|
||||
reason="test partial apply",
|
||||
)
|
||||
|
||||
failure = assert_single_failure(result, LayerImplementationError)
|
||||
assert str(failure) == "Layer 'second-layer': _save_to_store() failed"
|
||||
assert isinstance(failure.__cause__, RuntimeError)
|
||||
assert first_layer._data == {"first": "updated-one"}
|
||||
assert second_layer._data == {"second": "two"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
pytest.param(
|
||||
ConcurrencyConflictError(expected_fp="before", actual_fp="after"),
|
||||
id="concurrency-conflict",
|
||||
),
|
||||
pytest.param(ConfigPatchApplicationError("test"), id="patch-application-error"),
|
||||
pytest.param(RuntimeError("unexpected bug"), id="unexpected-runtime-error"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_returns_layer_apply_error_in_failures(
|
||||
error: Exception,
|
||||
) -> None:
|
||||
layer = ApplyErrorLayer(name="test", data={"value": "hello"}, error=error)
|
||||
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer])
|
||||
|
||||
result = await orch.apply_patch(
|
||||
[
|
||||
ReplaceOperationPatch(
|
||||
path="/value", value="updated", target_layer_name="test"
|
||||
)
|
||||
],
|
||||
reason="test update",
|
||||
)
|
||||
|
||||
assert assert_single_failure(result, type(error)) is error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_returns_unloaded_layer_error_in_failures() -> None:
|
||||
loaded_layer = FakeLayer(name="loaded", data={"value": "hello"})
|
||||
target_layer = FakeLayer(name="target", data={"value": "hello"})
|
||||
orch = await ConfigOrchestrator.create(
|
||||
schema=SimpleSchema, layers=[loaded_layer, target_layer]
|
||||
)
|
||||
await target_layer.invalidate_cache()
|
||||
|
||||
result = await orch.apply_patch(
|
||||
[
|
||||
ReplaceOperationPatch(
|
||||
path="/value", value="updated", target_layer_name="target"
|
||||
)
|
||||
],
|
||||
reason="test update",
|
||||
)
|
||||
|
||||
failure = assert_single_failure(result, LayerNotLoadedError)
|
||||
assert str(failure) == "Layer 'target' must be loaded before applying patches"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_applies_layers_in_parallel() -> None:
|
||||
barrier = ParallelSaveBarrier(expected_starts=2)
|
||||
first_layer = FieldWritableLayer(
|
||||
name="first-layer", field_name="first", data={"first": "one"}, barrier=barrier
|
||||
)
|
||||
second_layer = FieldWritableLayer(
|
||||
name="second-layer",
|
||||
field_name="second",
|
||||
data={"second": "two"},
|
||||
barrier=barrier,
|
||||
)
|
||||
orch = await ConfigOrchestrator.create(
|
||||
schema=MultiValueSchema, layers=[first_layer, second_layer]
|
||||
)
|
||||
|
||||
result = await orch.apply_patch(
|
||||
[
|
||||
ReplaceOperationPatch(
|
||||
path="/first", value="updated-one", target_layer_name="first-layer"
|
||||
),
|
||||
ReplaceOperationPatch(
|
||||
path="/second", value="updated-two", target_layer_name="second-layer"
|
||||
),
|
||||
],
|
||||
reason="test parallel apply",
|
||||
)
|
||||
|
||||
assert result == []
|
||||
assert barrier.started_layers == ["first-layer", "second-layer"]
|
||||
assert first_layer._data == {"first": "updated-one"}
|
||||
assert second_layer._data == {"second": "updated-two"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_end_to_end_updates_real_user_config_file(
|
||||
tmp_working_directory: Path,
|
||||
) -> None:
|
||||
toml_path = tmp_working_directory / "config.toml"
|
||||
toml_path.write_text(
|
||||
"""\
|
||||
active_model = "old"
|
||||
|
||||
[tools]
|
||||
disabled_tools = ["bash", "python"]
|
||||
deprecated_setting = true
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
user_layer = UserConfigLayer(path=toml_path)
|
||||
orch = await ConfigOrchestrator.create(schema=ToolSchema, layers=[user_layer])
|
||||
|
||||
result = await orch.apply_patch(
|
||||
[
|
||||
ReplaceOperationPatch(path="/active_model", value="new"),
|
||||
AddOperationPatch(path="/tools/enabled_tools", value=["read"]),
|
||||
AddOperationPatch(path="/tools/disabled_tools/-", value="node"),
|
||||
RemoveOperationPatch(path="/tools/disabled_tools/0"),
|
||||
RemoveOperationPatch(path="/tools/deprecated_setting"),
|
||||
],
|
||||
reason="update user defaults",
|
||||
)
|
||||
|
||||
assert result == []
|
||||
with toml_path.open("rb") as file:
|
||||
assert tomllib.load(file) == {
|
||||
"active_model": "new",
|
||||
"tools": {"disabled_tools": ["python", "node"], "enabled_tools": ["read"]},
|
||||
}
|
||||
assert orch.config.active_model == "new"
|
||||
assert orch.config.tools.disabled_tools == ["python", "node"]
|
||||
assert orch.config.tools.enabled_tools == ["read"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_fallback_to_user_layer_fails_when_user_file_is_missing(
|
||||
tmp_working_directory: Path,
|
||||
) -> None:
|
||||
toml_path = tmp_working_directory / "config.toml"
|
||||
user_layer = UserConfigLayer(path=toml_path)
|
||||
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[user_layer])
|
||||
|
||||
result = await orch.apply_patch(
|
||||
[AddOperationPatch(path="/value", value="created-later")],
|
||||
reason="fallback write without user file",
|
||||
)
|
||||
|
||||
failure = assert_single_failure(result, LayerNotLoadedError)
|
||||
assert str(failure) == "Layer 'user-toml' must be loaded before applying patches"
|
||||
assert not toml_path.exists()
|
||||
assert orch.config.value == "default"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_end_to_end_falls_back_to_user_layer_when_no_target_is_provided(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_working_directory: Path
|
||||
) -> None:
|
||||
toml_path = tmp_working_directory / "config.toml"
|
||||
toml_path.write_text('default_agent = "plan"\n', encoding="utf-8")
|
||||
monkeypatch.setenv("VIBE_ACTIVE_MODEL", "env-model")
|
||||
|
||||
user_layer = UserConfigLayer(path=toml_path)
|
||||
environment_layer = EnvironmentLayer(schema=RoutingSchema)
|
||||
orch = await ConfigOrchestrator.create(
|
||||
schema=RoutingSchema, layers=[user_layer, environment_layer]
|
||||
)
|
||||
|
||||
result = await orch.apply_patch(
|
||||
[
|
||||
AddOperationPatch(path="/active_model", value="persisted-in-user-file"),
|
||||
ReplaceOperationPatch(path="/default_agent", value="accept-edits"),
|
||||
],
|
||||
reason="update runtime defaults",
|
||||
)
|
||||
|
||||
assert result == []
|
||||
with toml_path.open("rb") as file:
|
||||
assert tomllib.load(file) == {
|
||||
"active_model": "persisted-in-user-file",
|
||||
"default_agent": "accept-edits",
|
||||
}
|
||||
assert orch.config.active_model == "env-model"
|
||||
assert orch.config.default_agent == "accept-edits"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_end_to_end_respects_explicit_target_layer(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_working_directory: Path
|
||||
) -> None:
|
||||
toml_path = tmp_working_directory / "config.toml"
|
||||
toml_path.write_text('default_agent = "plan"\n', encoding="utf-8")
|
||||
monkeypatch.setenv("VIBE_ACTIVE_MODEL", "env-model")
|
||||
|
||||
user_layer = UserConfigLayer(path=toml_path)
|
||||
environment_layer = EnvironmentLayer(schema=RoutingSchema)
|
||||
orch = await ConfigOrchestrator.create(
|
||||
schema=RoutingSchema, layers=[user_layer, environment_layer]
|
||||
)
|
||||
|
||||
result = await orch.apply_patch(
|
||||
[
|
||||
ReplaceOperationPatch(
|
||||
path="/active_model",
|
||||
value="persist-me-nowhere",
|
||||
target_layer_name="environment",
|
||||
),
|
||||
ReplaceOperationPatch(path="/default_agent", value="accept-edits"),
|
||||
],
|
||||
reason="update runtime defaults",
|
||||
)
|
||||
|
||||
failure = assert_single_failure(result, NotImplementedError)
|
||||
assert str(failure) == "EnvironmentLayer patch persistence is not implemented yet"
|
||||
with toml_path.open("rb") as file:
|
||||
assert tomllib.load(file) == {"default_agent": "accept-edits"}
|
||||
assert orch.config.active_model == "env-model"
|
||||
assert orch.config.default_agent == "accept-edits"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_registers_on_the_bus() -> None:
|
||||
bus = EventBus()
|
||||
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[], bus=bus)
|
||||
received: list[ConfigChangeEvent] = []
|
||||
orch.subscribe(received.append)
|
||||
|
||||
event = ConfigChangeEvent(
|
||||
changed_keys=frozenset({"value"}), before={}, after={}, reason=""
|
||||
)
|
||||
bus.publish(event)
|
||||
|
||||
assert received == [event]
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ api_base = "https://api.mistral.ai/v1"
|
|||
from vibe.core.config.layers.user import UserConfigLayer
|
||||
from vibe.core.config.orchestrator import ConfigOrchestrator
|
||||
|
||||
layer = UserConfigLayer(path=toml_path, name="user-toml")
|
||||
layer = UserConfigLayer(path=toml_path)
|
||||
orchestrator = await ConfigOrchestrator.create(schema=MinimalSchema, layers=[layer])
|
||||
|
||||
assert orchestrator.config.models.active_model == "mistral-large"
|
||||
|
|
|
|||
614
tests/core/test_keyring.py
Normal file
614
tests/core/test_keyring.py
Normal file
|
|
@ -0,0 +1,614 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from threading import Event, Thread
|
||||
|
||||
import keyring
|
||||
from keyring.errors import KeyringError, PasswordDeleteError
|
||||
import pytest
|
||||
|
||||
import vibe.core.utils.keyring as keyring_utils
|
||||
from vibe.core.utils.keyring import (
|
||||
delete_api_key_from_keyring,
|
||||
get_api_key_from_keyring,
|
||||
set_api_key_in_keyring,
|
||||
)
|
||||
|
||||
_CURRENT_SERVICE = "ai.mistral.vibe"
|
||||
_RELEASED_LEGACY_SERVICE = "vibe"
|
||||
_LEGACY_SERVICES = (_RELEASED_LEGACY_SERVICE,)
|
||||
_ALL_SERVICES = (_CURRENT_SERVICE, *_LEGACY_SERVICES)
|
||||
|
||||
|
||||
def test_set_writes_to_current_vibe_service_and_deletes_legacy_services(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
writes: list[tuple[str, str, str]] = []
|
||||
deletes: list[tuple[str, str]] = []
|
||||
monkeypatch.setattr(
|
||||
keyring,
|
||||
"set_password",
|
||||
lambda service, username, password: writes.append((
|
||||
service,
|
||||
username,
|
||||
password,
|
||||
)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
keyring,
|
||||
"delete_password",
|
||||
lambda service, username: deletes.append((service, username)),
|
||||
)
|
||||
|
||||
set_api_key_in_keyring("CUSTOM_API_KEY", "new-key")
|
||||
|
||||
assert writes == [(_CURRENT_SERVICE, "CUSTOM_API_KEY", "new-key")]
|
||||
assert deletes == [(service, "CUSTOM_API_KEY") for service in _LEGACY_SERVICES]
|
||||
|
||||
|
||||
def test_set_ignores_legacy_deletion_errors_after_successful_write(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
writes: list[tuple[str, str, str]] = []
|
||||
monkeypatch.setattr(
|
||||
keyring,
|
||||
"set_password",
|
||||
lambda service, username, password: writes.append((
|
||||
service,
|
||||
username,
|
||||
password,
|
||||
)),
|
||||
)
|
||||
|
||||
def _delete_failed(service: str, username: str) -> None:
|
||||
raise KeyringError("delete failed")
|
||||
|
||||
monkeypatch.setattr(keyring, "delete_password", _delete_failed)
|
||||
|
||||
set_api_key_in_keyring("CUSTOM_API_KEY", "new-key")
|
||||
|
||||
assert writes == [(_CURRENT_SERVICE, "CUSTOM_API_KEY", "new-key")]
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "new-key"
|
||||
|
||||
|
||||
def test_set_populates_cache(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
keyring, "set_password", lambda service, username, password: None
|
||||
)
|
||||
|
||||
set_api_key_in_keyring("CUSTOM_API_KEY", "new-key")
|
||||
|
||||
# A subsequent read is served from cache without consulting the backend.
|
||||
def _fail(service: str, username: str) -> str | None:
|
||||
raise AssertionError("keyring must not be consulted; value is cached")
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", _fail)
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "new-key"
|
||||
|
||||
|
||||
def test_get_does_not_overwrite_concurrent_cache_write(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
read_started = Event()
|
||||
allow_read_to_finish = Event()
|
||||
results: list[str | None] = []
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def _get(service: str, username: str) -> str | None:
|
||||
if service != _CURRENT_SERVICE:
|
||||
return None
|
||||
read_started.set()
|
||||
assert allow_read_to_finish.wait(timeout=5)
|
||||
return "old-key"
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", _get)
|
||||
monkeypatch.setattr(
|
||||
keyring, "set_password", lambda service, username, password: None
|
||||
)
|
||||
monkeypatch.setattr(keyring, "delete_password", lambda service, username: None)
|
||||
|
||||
def _read_key() -> None:
|
||||
try:
|
||||
results.append(get_api_key_from_keyring("CUSTOM_API_KEY"))
|
||||
except BaseException as exc:
|
||||
errors.append(exc)
|
||||
|
||||
reader = Thread(target=_read_key)
|
||||
reader.start()
|
||||
assert read_started.wait(timeout=5)
|
||||
|
||||
set_api_key_in_keyring("CUSTOM_API_KEY", "new-key")
|
||||
allow_read_to_finish.set()
|
||||
reader.join(timeout=5)
|
||||
|
||||
assert not reader.is_alive()
|
||||
if errors:
|
||||
raise errors[0]
|
||||
assert results == ["new-key"]
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "new-key"
|
||||
|
||||
|
||||
def test_disabled_keyring_get_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("VIBE_TEST_DISABLE_KEYRING", "1")
|
||||
|
||||
def _fail(service: str, username: str) -> str | None:
|
||||
raise AssertionError("disabled keyring must not be consulted")
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", _fail)
|
||||
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") is None
|
||||
|
||||
|
||||
def test_disabled_keyring_set_raises_and_forgets_cache(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
keyring, "set_password", lambda service, username, password: None
|
||||
)
|
||||
set_api_key_in_keyring("CUSTOM_API_KEY", "cached-key")
|
||||
monkeypatch.setenv("VIBE_TEST_DISABLE_KEYRING", "1")
|
||||
|
||||
with pytest.raises(KeyringError):
|
||||
set_api_key_in_keyring("CUSTOM_API_KEY", "new-key")
|
||||
|
||||
monkeypatch.delenv("VIBE_TEST_DISABLE_KEYRING")
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: None)
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") is None
|
||||
|
||||
|
||||
def test_disabled_keyring_delete_forgets_cache(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
keyring, "set_password", lambda service, username, password: None
|
||||
)
|
||||
set_api_key_in_keyring("CUSTOM_API_KEY", "cached-key")
|
||||
monkeypatch.setenv("VIBE_TEST_DISABLE_KEYRING", "1")
|
||||
|
||||
delete_api_key_from_keyring("CUSTOM_API_KEY")
|
||||
|
||||
monkeypatch.delenv("VIBE_TEST_DISABLE_KEYRING")
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: None)
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") is None
|
||||
|
||||
|
||||
def test_set_forgets_stale_cache_and_propagates_on_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Seed the cache with a stale value.
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: "stale")
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "stale"
|
||||
|
||||
def _unavailable(service: str, username: str, password: str) -> None:
|
||||
raise KeyringError("no keyring")
|
||||
|
||||
monkeypatch.setattr(keyring, "set_password", _unavailable)
|
||||
|
||||
with pytest.raises(KeyringError):
|
||||
set_api_key_in_keyring("CUSTOM_API_KEY", "new-key")
|
||||
|
||||
# The stale entry was dropped, so the next read consults the backend again.
|
||||
reads: list[tuple[str, str]] = []
|
||||
|
||||
def _get(service: str, username: str) -> str | None:
|
||||
reads.append((service, username))
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", _get)
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") is None
|
||||
assert reads == [
|
||||
(_CURRENT_SERVICE, "CUSTOM_API_KEY"),
|
||||
(_RELEASED_LEGACY_SERVICE, "CUSTOM_API_KEY"),
|
||||
]
|
||||
|
||||
|
||||
def test_get_prefers_current_service(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
reads: list[tuple[str, str]] = []
|
||||
|
||||
def _get(service: str, username: str) -> str | None:
|
||||
reads.append((service, username))
|
||||
if service == _CURRENT_SERVICE:
|
||||
return "current-key"
|
||||
return "legacy-key"
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", _get)
|
||||
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "current-key"
|
||||
assert reads == [(_CURRENT_SERVICE, "CUSTOM_API_KEY")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("legacy_service", _LEGACY_SERVICES)
|
||||
def test_get_migrates_legacy_service_value(
|
||||
monkeypatch: pytest.MonkeyPatch, legacy_service: str
|
||||
) -> None:
|
||||
reads: list[tuple[str, str]] = []
|
||||
writes: list[tuple[str, str, str]] = []
|
||||
deletes: list[tuple[str, str]] = []
|
||||
|
||||
def _get(service: str, username: str) -> str | None:
|
||||
reads.append((service, username))
|
||||
if service == legacy_service:
|
||||
return "legacy-key"
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", _get)
|
||||
monkeypatch.setattr(
|
||||
keyring,
|
||||
"set_password",
|
||||
lambda service, username, password: writes.append((
|
||||
service,
|
||||
username,
|
||||
password,
|
||||
)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
keyring,
|
||||
"delete_password",
|
||||
lambda service, username: deletes.append((service, username)),
|
||||
)
|
||||
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "legacy-key"
|
||||
expected_services = _ALL_SERVICES[: _ALL_SERVICES.index(legacy_service) + 1]
|
||||
assert reads == [(service, "CUSTOM_API_KEY") for service in expected_services]
|
||||
assert writes == [(_CURRENT_SERVICE, "CUSTOM_API_KEY", "legacy-key")]
|
||||
assert deletes == [(legacy_service, "CUSTOM_API_KEY")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("legacy_service", _LEGACY_SERVICES)
|
||||
def test_get_returns_legacy_value_when_migration_write_fails(
|
||||
monkeypatch: pytest.MonkeyPatch, legacy_service: str
|
||||
) -> None:
|
||||
deletes: list[tuple[str, str]] = []
|
||||
|
||||
def _get(service: str, username: str) -> str | None:
|
||||
if service == legacy_service:
|
||||
return "legacy-key"
|
||||
return None
|
||||
|
||||
def _unavailable(service: str, username: str, password: str) -> None:
|
||||
raise KeyringError("no keyring")
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", _get)
|
||||
monkeypatch.setattr(keyring, "set_password", _unavailable)
|
||||
monkeypatch.setattr(
|
||||
keyring,
|
||||
"delete_password",
|
||||
lambda service, username: deletes.append((service, username)),
|
||||
)
|
||||
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "legacy-key"
|
||||
assert deletes == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("legacy_service", _LEGACY_SERVICES)
|
||||
def test_get_ignores_legacy_deletion_error_after_migration(
|
||||
monkeypatch: pytest.MonkeyPatch, legacy_service: str
|
||||
) -> None:
|
||||
def _get(service: str, username: str) -> str | None:
|
||||
if service == legacy_service:
|
||||
return "legacy-key"
|
||||
return None
|
||||
|
||||
def _delete_failed(service: str, username: str) -> None:
|
||||
raise KeyringError("delete failed")
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", _get)
|
||||
monkeypatch.setattr(
|
||||
keyring, "set_password", lambda service, username, password: None
|
||||
)
|
||||
monkeypatch.setattr(keyring, "delete_password", _delete_failed)
|
||||
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "legacy-key"
|
||||
|
||||
|
||||
def test_delete_uses_current_and_legacy_vibe_services_and_forgets_cache(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Seed the cache.
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: "cached")
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "cached"
|
||||
|
||||
deleted: list[tuple[str, str]] = []
|
||||
monkeypatch.setattr(
|
||||
keyring,
|
||||
"delete_password",
|
||||
lambda service, username: deleted.append((service, username)),
|
||||
)
|
||||
|
||||
delete_api_key_from_keyring("CUSTOM_API_KEY")
|
||||
|
||||
assert deleted == [(service, "CUSTOM_API_KEY") for service in _ALL_SERVICES]
|
||||
# Cache entry dropped: the next read consults the backend.
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: None)
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") is None
|
||||
|
||||
|
||||
def test_delete_forgets_cache_even_when_backend_raises(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: "cached")
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "cached"
|
||||
|
||||
def _unavailable(service: str, username: str) -> None:
|
||||
raise KeyringError("no keyring")
|
||||
|
||||
monkeypatch.setattr(keyring, "delete_password", _unavailable)
|
||||
|
||||
with pytest.raises(KeyringError):
|
||||
delete_api_key_from_keyring("CUSTOM_API_KEY")
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: None)
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") is None
|
||||
|
||||
|
||||
def test_delete_attempts_legacy_service_when_current_service_raises(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
deleted: list[tuple[str, str]] = []
|
||||
|
||||
def _delete(service: str, username: str) -> None:
|
||||
deleted.append((service, username))
|
||||
if service == _CURRENT_SERVICE:
|
||||
raise KeyringError("delete failed")
|
||||
|
||||
monkeypatch.setattr(keyring, "delete_password", _delete)
|
||||
|
||||
with pytest.raises(KeyringError):
|
||||
delete_api_key_from_keyring("CUSTOM_API_KEY")
|
||||
|
||||
assert deleted == [(service, "CUSTOM_API_KEY") for service in _ALL_SERVICES]
|
||||
|
||||
|
||||
def test_delete_prefers_operation_error_over_missing_entry(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
deleted: list[tuple[str, str]] = []
|
||||
|
||||
def _delete(service: str, username: str) -> None:
|
||||
deleted.append((service, username))
|
||||
if service == _CURRENT_SERVICE:
|
||||
raise PasswordDeleteError()
|
||||
if service == _RELEASED_LEGACY_SERVICE:
|
||||
raise KeyringError("delete failed")
|
||||
raise PasswordDeleteError()
|
||||
|
||||
monkeypatch.setattr(keyring, "delete_password", _delete)
|
||||
|
||||
with pytest.raises(KeyringError, match="delete failed"):
|
||||
delete_api_key_from_keyring("CUSTOM_API_KEY")
|
||||
|
||||
assert deleted == [(service, "CUSTOM_API_KEY") for service in _ALL_SERVICES]
|
||||
|
||||
|
||||
def test_delete_ignores_missing_entries_after_successful_delete(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
deleted: list[tuple[str, str]] = []
|
||||
|
||||
def _delete(service: str, username: str) -> None:
|
||||
deleted.append((service, username))
|
||||
if service != _RELEASED_LEGACY_SERVICE:
|
||||
raise PasswordDeleteError()
|
||||
|
||||
monkeypatch.setattr(keyring, "delete_password", _delete)
|
||||
|
||||
delete_api_key_from_keyring("CUSTOM_API_KEY")
|
||||
|
||||
assert deleted == [(service, "CUSTOM_API_KEY") for service in _ALL_SERVICES]
|
||||
|
||||
|
||||
def test_delete_raises_missing_when_all_services_are_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
deleted: list[tuple[str, str]] = []
|
||||
|
||||
def _delete(service: str, username: str) -> None:
|
||||
deleted.append((service, username))
|
||||
raise PasswordDeleteError()
|
||||
|
||||
monkeypatch.setattr(keyring, "delete_password", _delete)
|
||||
|
||||
with pytest.raises(PasswordDeleteError):
|
||||
delete_api_key_from_keyring("CUSTOM_API_KEY")
|
||||
|
||||
assert deleted == [(service, "CUSTOM_API_KEY") for service in _ALL_SERVICES]
|
||||
|
||||
|
||||
def test_macos_set_recreates_item_with_security_stdin_and_unrestricted_acl(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[tuple[list[str], dict[str, object]]] = []
|
||||
monkeypatch.setattr(keyring_utils, "_should_use_macos_security", lambda: True)
|
||||
|
||||
def _run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
calls.append((args, kwargs))
|
||||
return subprocess.CompletedProcess(args, 0)
|
||||
|
||||
monkeypatch.setattr(keyring_utils.subprocess, "run", _run)
|
||||
|
||||
set_api_key_in_keyring("CUSTOM_API_KEY", "new-key")
|
||||
|
||||
assert len(calls) == 3
|
||||
delete_args, delete_kwargs = calls[0]
|
||||
assert delete_args == [
|
||||
"/usr/bin/security",
|
||||
"delete-generic-password",
|
||||
"-s",
|
||||
_CURRENT_SERVICE,
|
||||
"-a",
|
||||
"CUSTOM_API_KEY",
|
||||
]
|
||||
assert "new-key" not in delete_args
|
||||
assert delete_kwargs["input"] is None
|
||||
|
||||
args, kwargs = calls[1]
|
||||
assert args == ["/usr/bin/security", "-i"]
|
||||
assert kwargs["text"] is True
|
||||
assert kwargs["capture_output"] is True
|
||||
assert kwargs["check"] is True
|
||||
assert "new-key" not in args
|
||||
|
||||
command = kwargs["input"]
|
||||
assert isinstance(command, str)
|
||||
assert command.endswith("\n")
|
||||
assert "add-generic-password" in command
|
||||
assert "-A" in command
|
||||
assert "-U" not in command
|
||||
assert _CURRENT_SERVICE in command
|
||||
assert "CUSTOM_API_KEY" in command
|
||||
assert "new-key" in command
|
||||
assert [call[0] for call in calls[2:]] == [
|
||||
[
|
||||
"/usr/bin/security",
|
||||
"delete-generic-password",
|
||||
"-s",
|
||||
_RELEASED_LEGACY_SERVICE,
|
||||
"-a",
|
||||
"CUSTOM_API_KEY",
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
def test_macos_set_ignores_missing_item_before_recreate(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[list[str]] = []
|
||||
monkeypatch.setattr(keyring_utils, "_should_use_macos_security", lambda: True)
|
||||
|
||||
def _run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
calls.append(args)
|
||||
if args[1] == "delete-generic-password":
|
||||
raise subprocess.CalledProcessError(
|
||||
44,
|
||||
args,
|
||||
stderr="The specified item could not be found in the keychain.",
|
||||
)
|
||||
return subprocess.CompletedProcess(args, 0)
|
||||
|
||||
monkeypatch.setattr(keyring_utils.subprocess, "run", _run)
|
||||
|
||||
set_api_key_in_keyring("CUSTOM_API_KEY", "new-key")
|
||||
|
||||
assert calls == [
|
||||
[
|
||||
"/usr/bin/security",
|
||||
"delete-generic-password",
|
||||
"-s",
|
||||
_CURRENT_SERVICE,
|
||||
"-a",
|
||||
"CUSTOM_API_KEY",
|
||||
],
|
||||
["/usr/bin/security", "-i"],
|
||||
[
|
||||
"/usr/bin/security",
|
||||
"delete-generic-password",
|
||||
"-s",
|
||||
_RELEASED_LEGACY_SERVICE,
|
||||
"-a",
|
||||
"CUSTOM_API_KEY",
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
def test_macos_get_uses_security_find_password(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[tuple[list[str], dict[str, object]]] = []
|
||||
monkeypatch.setattr(keyring_utils, "_should_use_macos_security", lambda: True)
|
||||
|
||||
def _run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
calls.append((args, kwargs))
|
||||
return subprocess.CompletedProcess(args, 0, stdout="stored-key\n")
|
||||
|
||||
monkeypatch.setattr(keyring_utils.subprocess, "run", _run)
|
||||
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "stored-key"
|
||||
assert calls == [
|
||||
(
|
||||
[
|
||||
"/usr/bin/security",
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
_CURRENT_SERVICE,
|
||||
"-a",
|
||||
"CUSTOM_API_KEY",
|
||||
"-w",
|
||||
],
|
||||
{"input": None, "text": True, "capture_output": True, "check": True},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_macos_get_raises_when_item_is_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(keyring_utils, "_should_use_macos_security", lambda: True)
|
||||
|
||||
def _run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
raise subprocess.CalledProcessError(
|
||||
44, args, stderr="The specified item could not be found in the keychain."
|
||||
)
|
||||
|
||||
monkeypatch.setattr(keyring_utils.subprocess, "run", _run)
|
||||
|
||||
with pytest.raises(keyring_utils._PasswordNotFoundError):
|
||||
keyring_utils._get_password(_CURRENT_SERVICE, "CUSTOM_API_KEY")
|
||||
|
||||
|
||||
def test_macos_get_checks_legacy_service_when_current_item_is_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[list[str]] = []
|
||||
monkeypatch.setattr(keyring_utils, "_should_use_macos_security", lambda: True)
|
||||
|
||||
def _run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
calls.append(args)
|
||||
if args[1] in {"find-generic-password", "delete-generic-password"} and (
|
||||
args[3] == _CURRENT_SERVICE
|
||||
):
|
||||
raise subprocess.CalledProcessError(
|
||||
44,
|
||||
args,
|
||||
stderr="The specified item could not be found in the keychain.",
|
||||
)
|
||||
return subprocess.CompletedProcess(args, 0, stdout="legacy-key\n")
|
||||
|
||||
monkeypatch.setattr(keyring_utils.subprocess, "run", _run)
|
||||
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "legacy-key"
|
||||
assert calls[:2] == [
|
||||
[
|
||||
"/usr/bin/security",
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
_CURRENT_SERVICE,
|
||||
"-a",
|
||||
"CUSTOM_API_KEY",
|
||||
"-w",
|
||||
],
|
||||
[
|
||||
"/usr/bin/security",
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
_RELEASED_LEGACY_SERVICE,
|
||||
"-a",
|
||||
"CUSTOM_API_KEY",
|
||||
"-w",
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
def test_macos_set_wraps_security_failure_and_forgets_cache(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: "stale")
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") == "stale"
|
||||
|
||||
monkeypatch.setattr(keyring_utils, "_should_use_macos_security", lambda: True)
|
||||
|
||||
def _run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
if args[1] == "delete-generic-password":
|
||||
return subprocess.CompletedProcess(args, 0)
|
||||
raise subprocess.CalledProcessError(1, args)
|
||||
|
||||
monkeypatch.setattr(keyring_utils.subprocess, "run", _run)
|
||||
|
||||
with pytest.raises(KeyringError):
|
||||
set_api_key_in_keyring("CUSTOM_API_KEY", "new-key")
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: None)
|
||||
assert get_api_key_from_keyring("CUSTOM_API_KEY") is None
|
||||
|
|
@ -31,6 +31,8 @@ from vibe.core.auth.mcp_oauth import (
|
|||
)
|
||||
from vibe.core.config import MCPOAuth, MCPStreamableHttp
|
||||
|
||||
_KEYRING_SERVICE = "ai.mistral.vibe"
|
||||
|
||||
|
||||
class _MemoryKeyring(KeyringBackend):
|
||||
priority = 100 # type: ignore[assignment]
|
||||
|
|
@ -46,7 +48,7 @@ class _MemoryKeyring(KeyringBackend):
|
|||
|
||||
def delete_password(self, service: str, username: str) -> None:
|
||||
if (service, username) not in self.store:
|
||||
raise keyring.errors.PasswordDeleteError(username)
|
||||
raise keyring.errors.PasswordDeleteError()
|
||||
del self.store[(service, username)]
|
||||
|
||||
|
||||
|
|
@ -143,7 +145,7 @@ class TestKeyringTokenStorage:
|
|||
assert loaded.access_token == "at"
|
||||
assert loaded.refresh_token == "rt"
|
||||
assert loaded.scope == "read write"
|
||||
assert ("vibe", "mcp-oauth:linear:tokens") in memory_keyring.store
|
||||
assert (_KEYRING_SERVICE, "mcp-oauth:linear:tokens") in memory_keyring.store
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_round_trip_client_info(self, memory_keyring: _MemoryKeyring) -> None:
|
||||
|
|
@ -160,7 +162,10 @@ class TestKeyringTokenStorage:
|
|||
loaded = await storage.get_client_info()
|
||||
assert loaded is not None
|
||||
assert loaded.client_id == "abc123"
|
||||
assert ("vibe", "mcp-oauth:linear:client_info") in memory_keyring.store
|
||||
assert (
|
||||
_KEYRING_SERVICE,
|
||||
"mcp-oauth:linear:client_info",
|
||||
) in memory_keyring.store
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_alias_isolation(self, memory_keyring: _MemoryKeyring) -> None:
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import pytest
|
|||
|
||||
from vibe.core.config.layer import UntrustedLayerError
|
||||
from vibe.core.config.layers.project import ProjectConfigLayer
|
||||
from vibe.core.config.types import MISSING_CONFIG_FILE_FINGERPRINT
|
||||
from vibe.core.config.types import MISSING_BACKING_STORE_DATA_FINGERPRINT
|
||||
from vibe.core.paths._vibe_home import GlobalPath
|
||||
from vibe.core.trusted_folders import trusted_folders_manager
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ async def test_reads_toml_when_trusted(tmp_working_directory: Path) -> None:
|
|||
config_path.unlink()
|
||||
data = await layer.load(force=True)
|
||||
assert data.model_extra == {}
|
||||
assert layer.fingerprint == MISSING_CONFIG_FILE_FINGERPRINT
|
||||
assert layer.fingerprint == MISSING_BACKING_STORE_DATA_FINGERPRINT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -63,7 +63,7 @@ async def test_missing_file_returns_empty(tmp_working_directory: Path) -> None:
|
|||
layer = ProjectConfigLayer(path=tmp_working_directory)
|
||||
data = await layer.load()
|
||||
assert data.model_extra == {}
|
||||
assert layer.fingerprint == MISSING_CONFIG_FILE_FINGERPRINT
|
||||
assert layer.fingerprint == MISSING_BACKING_STORE_DATA_FINGERPRINT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from tests.conftest import build_test_vibe_config
|
|||
from vibe.core.config import MissingAPIKeyError, ProviderConfig, resolve_api_key
|
||||
from vibe.core.llm.backend.mistral import MistralBackend
|
||||
from vibe.core.types import Backend
|
||||
from vibe.core.utils.keyring import clear_api_key_keyring_cache
|
||||
|
||||
|
||||
def test_resolve_returns_env_value_without_consulting_keyring(
|
||||
|
|
@ -34,6 +35,40 @@ def test_resolve_falls_back_to_keyring_when_env_unset(
|
|||
assert resolve_api_key("CUSTOM_API_KEY") == "keyring-key"
|
||||
|
||||
|
||||
def test_resolve_caches_keyring_lookup_for_env_key(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[tuple[str, str]] = []
|
||||
monkeypatch.delenv("CUSTOM_API_KEY", raising=False)
|
||||
|
||||
def _get_password(service: str, username: str) -> str:
|
||||
calls.append((service, username))
|
||||
return "keyring-key"
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", _get_password)
|
||||
|
||||
assert resolve_api_key("CUSTOM_API_KEY") == "keyring-key"
|
||||
assert resolve_api_key("CUSTOM_API_KEY") == "keyring-key"
|
||||
assert calls == [("ai.mistral.vibe", "CUSTOM_API_KEY")]
|
||||
|
||||
|
||||
def test_resolve_caches_empty_keyring_lookup_for_env_key(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[tuple[str, str]] = []
|
||||
monkeypatch.delenv("CUSTOM_API_KEY", raising=False)
|
||||
|
||||
def _get_password(service: str, username: str) -> None:
|
||||
calls.append((service, username))
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", _get_password)
|
||||
|
||||
assert resolve_api_key("CUSTOM_API_KEY") is None
|
||||
assert resolve_api_key("CUSTOM_API_KEY") is None
|
||||
assert calls == [("ai.mistral.vibe", "CUSTOM_API_KEY"), ("vibe", "CUSTOM_API_KEY")]
|
||||
|
||||
|
||||
def test_resolve_returns_none_when_env_unset_and_keyring_empty(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
@ -56,6 +91,24 @@ def test_resolve_returns_none_when_keyring_raises(
|
|||
assert resolve_api_key("CUSTOM_API_KEY") is None
|
||||
|
||||
|
||||
def test_resolve_does_not_cache_keyring_errors(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = 0
|
||||
monkeypatch.delenv("CUSTOM_API_KEY", raising=False)
|
||||
|
||||
def _sometimes_unavailable(service: str, username: str) -> str:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise KeyringError("temporary failure")
|
||||
return "keyring-key"
|
||||
|
||||
monkeypatch.setattr(keyring, "get_password", _sometimes_unavailable)
|
||||
|
||||
assert resolve_api_key("CUSTOM_API_KEY") is None
|
||||
assert resolve_api_key("CUSTOM_API_KEY") == "keyring-key"
|
||||
assert calls == 2
|
||||
|
||||
|
||||
def test_resolve_returns_none_for_empty_env_key(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
@ -123,7 +176,7 @@ def test_vibe_code_api_key_resolves_from_keyring(
|
|||
assert config.vibe_code_api_key == "keyring-key"
|
||||
|
||||
|
||||
def test_vibe_code_api_key_empty_when_unresolved(
|
||||
def test_vibe_code_api_key_reuses_cached_keyring_value(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
|
|
@ -132,7 +185,21 @@ def test_vibe_code_api_key_empty_when_unresolved(
|
|||
)
|
||||
config = build_test_vibe_config()
|
||||
|
||||
# Nothing resolves the key anymore; the property must return "" (not None).
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: None)
|
||||
|
||||
assert config.vibe_code_api_key == "keyring-key"
|
||||
|
||||
|
||||
def test_vibe_code_api_key_empty_when_cache_cleared_and_unresolved(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
monkeypatch.setattr(
|
||||
keyring, "get_password", lambda service, username: "keyring-key"
|
||||
)
|
||||
config = build_test_vibe_config()
|
||||
|
||||
clear_api_key_keyring_cache()
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: None)
|
||||
|
||||
assert config.vibe_code_api_key == ""
|
||||
|
|
|
|||
|
|
@ -61,9 +61,6 @@ def _make_agent_loop(backend: FakeBackend):
|
|||
"edit": {"permission": "always"},
|
||||
"bash": {"permission": "always"},
|
||||
},
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
)
|
||||
return build_test_agent_loop(
|
||||
config=config, agent_name=BuiltinAgentName.AUTO_APPROVE, backend=backend
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ from vibe.core.config.patch import (
|
|||
RemoveOperationPatch,
|
||||
ReplaceOperationPatch,
|
||||
)
|
||||
from vibe.core.config.types import MISSING_CONFIG_FILE_FINGERPRINT
|
||||
from vibe.core.config.types import MISSING_BACKING_STORE_DATA_FINGERPRINT
|
||||
|
||||
|
||||
def random_config_file_name() -> str:
|
||||
|
|
@ -28,7 +28,7 @@ async def test_reads_toml_file(tmp_working_directory: Path) -> None:
|
|||
path = tmp_working_directory / random_config_file_name()
|
||||
path.write_text('active_model = "mistral-large"\ncount = 42\n')
|
||||
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
data = await layer.load()
|
||||
assert data.model_extra == {"active_model": "mistral-large", "count": 42}
|
||||
fingerprint = layer.fingerprint
|
||||
|
|
@ -41,7 +41,7 @@ async def test_always_trusted(tmp_working_directory: Path) -> None:
|
|||
path = tmp_working_directory / random_config_file_name()
|
||||
path.write_text('key = "value"\n')
|
||||
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
assert layer.is_trusted is None
|
||||
data = await layer.load()
|
||||
assert layer.is_trusted is True
|
||||
|
|
@ -51,10 +51,10 @@ async def test_always_trusted(tmp_working_directory: Path) -> None:
|
|||
@pytest.mark.asyncio
|
||||
async def test_missing_file_returns_empty(tmp_working_directory: Path) -> None:
|
||||
path = tmp_working_directory / random_config_file_name()
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
data = await layer.load()
|
||||
assert data.model_extra == {}
|
||||
assert layer.fingerprint == MISSING_CONFIG_FILE_FINGERPRINT
|
||||
assert layer.fingerprint == MISSING_BACKING_STORE_DATA_FINGERPRINT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -62,16 +62,16 @@ async def test_apply_raises_when_file_does_not_exist(
|
|||
tmp_working_directory: Path,
|
||||
) -> None:
|
||||
path = tmp_working_directory / random_config_file_name()
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
|
||||
await layer.load()
|
||||
assert layer.fingerprint == MISSING_CONFIG_FILE_FINGERPRINT
|
||||
assert layer.fingerprint == MISSING_BACKING_STORE_DATA_FINGERPRINT
|
||||
|
||||
with pytest.raises(LayerNotLoadedError, match="loaded before applying patches"):
|
||||
await layer.apply(
|
||||
ConfigPatch(
|
||||
AddOperationPatch(path="/active_model", value="mistral-large"),
|
||||
fingerprint=MISSING_CONFIG_FILE_FINGERPRINT,
|
||||
fingerprint=MISSING_BACKING_STORE_DATA_FINGERPRINT,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ active_model = "old"
|
|||
disabled_tools = ["bash", "python"]
|
||||
deprecated_setting = true
|
||||
""")
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
|
||||
await layer.load()
|
||||
fingerprint = layer.fingerprint
|
||||
|
|
@ -126,7 +126,7 @@ async def test_apply_cache_fingerprint_matches_written_file(
|
|||
) -> None:
|
||||
path = tmp_working_directory / random_config_file_name()
|
||||
path.write_text("")
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
|
||||
await layer.load()
|
||||
fingerprint = layer.fingerprint
|
||||
|
|
@ -149,7 +149,7 @@ async def test_apply_uses_unique_temp_file(tmp_working_directory: Path) -> None:
|
|||
fixed_tmp_path = tmp_working_directory / f".{path.name}.tmp"
|
||||
path.write_text("")
|
||||
fixed_tmp_path.write_text("stale")
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
|
||||
await layer.load()
|
||||
fingerprint = layer.fingerprint
|
||||
|
|
@ -190,13 +190,13 @@ async def test_apply_raises_when_layer_is_not_loaded(
|
|||
tmp_working_directory: Path,
|
||||
) -> None:
|
||||
path = tmp_working_directory / random_config_file_name()
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
|
||||
with pytest.raises(LayerNotLoadedError, match="loaded before applying patches"):
|
||||
await layer.apply(
|
||||
ConfigPatch(
|
||||
AddOperationPatch(path="/active_model", value="mistral-large"),
|
||||
fingerprint=MISSING_CONFIG_FILE_FINGERPRINT,
|
||||
fingerprint=MISSING_BACKING_STORE_DATA_FINGERPRINT,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -207,7 +207,7 @@ async def test_apply_raises_when_cache_is_invalidated(
|
|||
) -> None:
|
||||
path = tmp_working_directory / random_config_file_name()
|
||||
path.write_text('active_model = "old"\n')
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
|
||||
await layer.load()
|
||||
fingerprint = layer.fingerprint
|
||||
|
|
@ -228,7 +228,7 @@ async def test_apply_raises_when_parent_directory_does_not_exist(
|
|||
tmp_working_directory: Path,
|
||||
) -> None:
|
||||
path = tmp_working_directory / "nested" / random_config_file_name()
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
|
||||
await layer.load()
|
||||
|
||||
|
|
@ -236,7 +236,7 @@ async def test_apply_raises_when_parent_directory_does_not_exist(
|
|||
await layer.apply(
|
||||
ConfigPatch(
|
||||
AddOperationPatch(path="/active_model", value="mistral-large"),
|
||||
fingerprint=MISSING_CONFIG_FILE_FINGERPRINT,
|
||||
fingerprint=MISSING_BACKING_STORE_DATA_FINGERPRINT,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -247,7 +247,7 @@ async def test_apply_raises_when_parent_directory_does_not_exist(
|
|||
async def test_commit_sets_missing_nested_field(tmp_working_directory: Path) -> None:
|
||||
path = tmp_working_directory / random_config_file_name()
|
||||
path.write_text("[models]\n")
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
|
||||
await layer.load()
|
||||
fingerprint = layer.fingerprint
|
||||
|
|
@ -270,7 +270,7 @@ async def test_apply_overwrites_external_file_changes(
|
|||
) -> None:
|
||||
path = tmp_working_directory / random_config_file_name()
|
||||
path.write_text('active_model = "old"\n')
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
|
||||
await layer.load()
|
||||
fingerprint = layer.fingerprint
|
||||
|
|
@ -301,7 +301,7 @@ active_model = "test"
|
|||
alias = "a"
|
||||
provider = "p"
|
||||
""")
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
data = await layer.load()
|
||||
assert data.model_extra == {
|
||||
"models": {"active_model": "test", "items": [{"alias": "a", "provider": "p"}]}
|
||||
|
|
@ -312,7 +312,7 @@ provider = "p"
|
|||
async def test_invalid_toml_raises(tmp_working_directory: Path) -> None:
|
||||
path = tmp_working_directory / random_config_file_name()
|
||||
path.write_text("this is not valid = = = toml [[[")
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
with pytest.raises(LayerImplementationError, match="_build_config_snapshot"):
|
||||
await layer.load()
|
||||
|
||||
|
|
@ -321,7 +321,7 @@ async def test_invalid_toml_raises(tmp_working_directory: Path) -> None:
|
|||
async def test_force_reload_reads_fresh_data(tmp_working_directory: Path) -> None:
|
||||
path = tmp_working_directory / random_config_file_name()
|
||||
path.write_text('value = "first"\n')
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
|
||||
data1 = await layer.load()
|
||||
fp1 = layer.fingerprint
|
||||
|
|
@ -340,13 +340,13 @@ async def test_force_reload_reads_fresh_data(tmp_working_directory: Path) -> Non
|
|||
path.unlink()
|
||||
data3 = await layer.load(force=True)
|
||||
assert data3.model_extra == {}
|
||||
assert layer.fingerprint == MISSING_CONFIG_FILE_FINGERPRINT
|
||||
assert layer.fingerprint == MISSING_BACKING_STORE_DATA_FINGERPRINT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_toml_file(tmp_working_directory: Path) -> None:
|
||||
path = tmp_working_directory / random_config_file_name()
|
||||
path.write_text("")
|
||||
layer = UserConfigLayer(path=path, name="user-toml")
|
||||
layer = UserConfigLayer(path=path)
|
||||
data = await layer.load()
|
||||
assert data.model_extra == {}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,15 @@ from __future__ import annotations
|
|||
|
||||
from pathlib import Path
|
||||
|
||||
import keyring
|
||||
import pytest
|
||||
|
||||
from vibe.core.config import (
|
||||
DEFAULT_THEME,
|
||||
MissingAPIKeyError,
|
||||
ModelConfig,
|
||||
ProviderConfig,
|
||||
)
|
||||
from vibe.core.config._settings import VibeConfig
|
||||
from vibe.core.config.vibe_schema import VibeConfigSchema
|
||||
|
||||
|
|
@ -47,7 +54,7 @@ provider = "mistral"
|
|||
class VibeConfig(VibeConfigSchema):
|
||||
pass
|
||||
|
||||
layer = UserConfigLayer(path=toml_path, name="user-toml")
|
||||
layer = UserConfigLayer(path=toml_path)
|
||||
orchestrator = await ConfigOrchestrator[VibeConfig].create(
|
||||
schema=VibeConfig, layers=[layer]
|
||||
)
|
||||
|
|
@ -62,3 +69,43 @@ provider = "mistral"
|
|||
assert config.default_agent == "plan"
|
||||
assert "search" in config.enabled_skills
|
||||
assert config.enable_otel is True
|
||||
|
||||
|
||||
def test_duplicate_model_alias_raises() -> None:
|
||||
with pytest.raises(ValueError, match="Duplicate alias"):
|
||||
VibeConfigSchema(
|
||||
models=[
|
||||
ModelConfig(name="model-a", provider="mistral", alias="same"),
|
||||
ModelConfig(name="model-b", provider="mistral", alias="same"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_compaction_model_provider_must_match_active() -> None:
|
||||
providers = [
|
||||
ProviderConfig(
|
||||
name="mistral",
|
||||
api_base="https://api.mistral.ai/v1",
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
),
|
||||
ProviderConfig(
|
||||
name="other",
|
||||
api_base="https://other.ai/v1",
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
),
|
||||
]
|
||||
compaction = ModelConfig(name="compact-model", provider="other", alias="compact")
|
||||
with pytest.raises(ValueError, match="must share the same provider"):
|
||||
VibeConfigSchema(compaction_model=compaction, providers=providers)
|
||||
|
||||
|
||||
def test_check_api_key_raises_when_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
monkeypatch.setattr(keyring, "get_password", lambda service, username: None)
|
||||
with pytest.raises(MissingAPIKeyError):
|
||||
VibeConfigSchema()
|
||||
|
||||
|
||||
def test_unknown_theme_falls_back_to_default() -> None:
|
||||
config = VibeConfigSchema(theme="totally-unknown-theme")
|
||||
assert config.theme == DEFAULT_THEME
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue