v2.6.0 (#524)
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Gauthier Guinet <43207538+Gguinet@users.noreply.github.com> Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Quentin <torroba.q@gmail.com> Co-authored-by: Simon <80467011+sorgfresser@users.noreply.github.com> Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: angelapopopo <angele.lenglemetz@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
5103019b01
commit
eb580209d4
180 changed files with 11136 additions and 1030 deletions
127
tests/core/test_config_otel.py
Normal file
127
tests/core/test_config_otel.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.core.config import OtelExporterConfig, ProviderConfig, VibeConfig
|
||||
from vibe.core.types import Backend
|
||||
|
||||
|
||||
class TestOtelExporterConfig:
|
||||
def test_derives_endpoint_from_mistral_provider(
|
||||
self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "sk-test")
|
||||
config = vibe_config.model_copy(
|
||||
update={
|
||||
"providers": [
|
||||
ProviderConfig(
|
||||
name="mistral",
|
||||
api_base="https://customer.mistral.ai/v1",
|
||||
backend=Backend.MISTRAL,
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
result = config.otel_exporter_config
|
||||
assert result is not None
|
||||
assert result.endpoint == "https://customer.mistral.ai/telemetry/v1/traces"
|
||||
assert result.headers == {"Authorization": "Bearer sk-test"}
|
||||
|
||||
def test_uses_first_mistral_provider(
|
||||
self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("EU_KEY", "sk-eu")
|
||||
config = vibe_config.model_copy(
|
||||
update={
|
||||
"providers": [
|
||||
ProviderConfig(
|
||||
name="mistral-eu",
|
||||
api_base="https://eu.mistral.ai/v1",
|
||||
api_key_env_var="EU_KEY",
|
||||
backend=Backend.MISTRAL,
|
||||
),
|
||||
ProviderConfig(
|
||||
name="mistral-us",
|
||||
api_base="https://us.mistral.ai/v1",
|
||||
api_key_env_var="US_KEY",
|
||||
backend=Backend.MISTRAL,
|
||||
),
|
||||
]
|
||||
}
|
||||
)
|
||||
result = config.otel_exporter_config
|
||||
assert result is not None
|
||||
assert result.endpoint == "https://eu.mistral.ai/telemetry/v1/traces"
|
||||
assert result.headers == {"Authorization": "Bearer sk-eu"}
|
||||
|
||||
def test_falls_back_to_default_when_no_mistral_provider(
|
||||
self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "sk-fallback")
|
||||
config = vibe_config.model_copy(
|
||||
update={
|
||||
"providers": [
|
||||
ProviderConfig(
|
||||
name="anthropic", api_base="https://api.anthropic.com/v1"
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
result = config.otel_exporter_config
|
||||
assert result is not None
|
||||
assert result.endpoint == "https://api.mistral.ai/telemetry/v1/traces"
|
||||
assert result.headers == {"Authorization": "Bearer sk-fallback"}
|
||||
|
||||
def test_default_providers(
|
||||
self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "sk-default")
|
||||
result = vibe_config.otel_exporter_config
|
||||
assert result is not None
|
||||
assert result.endpoint == "https://api.mistral.ai/telemetry/v1/traces"
|
||||
|
||||
def test_returns_none_and_warns_when_api_key_missing(
|
||||
self,
|
||||
vibe_config: VibeConfig,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
with caplog.at_level("WARNING"):
|
||||
assert vibe_config.otel_exporter_config is None
|
||||
assert "OTEL tracing enabled but MISTRAL_API_KEY is not set" in caplog.text
|
||||
|
||||
def test_custom_api_key_env_var(
|
||||
self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
monkeypatch.setenv("MY_CUSTOM_KEY", "sk-custom")
|
||||
config = vibe_config.model_copy(
|
||||
update={
|
||||
"providers": [
|
||||
ProviderConfig(
|
||||
name="mistral-onprem",
|
||||
api_base="https://onprem.corp.com/v1",
|
||||
api_key_env_var="MY_CUSTOM_KEY",
|
||||
backend=Backend.MISTRAL,
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
result = config.otel_exporter_config
|
||||
assert result is not None
|
||||
assert result.endpoint == "https://onprem.corp.com/telemetry/v1/traces"
|
||||
assert result.headers == {"Authorization": "Bearer sk-custom"}
|
||||
|
||||
def test_explicit_otel_endpoint_bypasses_provider_derivation(
|
||||
self, vibe_config: VibeConfig
|
||||
) -> None:
|
||||
config = vibe_config.model_copy(
|
||||
update={"otel_endpoint": "https://my-collector:4318/v1/traces"}
|
||||
)
|
||||
result = config.otel_exporter_config
|
||||
assert result is not None
|
||||
assert result == OtelExporterConfig(
|
||||
endpoint="https://my-collector:4318/v1/traces"
|
||||
)
|
||||
assert result.headers is None
|
||||
135
tests/core/test_local_config_walk.py
Normal file
135
tests/core/test_local_config_walk.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from vibe.core.paths._local_config_walk import (
|
||||
_MAX_DIRS,
|
||||
WALK_MAX_DEPTH,
|
||||
has_config_dirs_nearby,
|
||||
walk_local_config_dirs_all,
|
||||
)
|
||||
|
||||
|
||||
class TestBoundedWalk:
|
||||
def test_finds_config_at_root(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
tools, skills, agents = walk_local_config_dirs_all(tmp_path)
|
||||
assert tmp_path / ".vibe" / "tools" in tools
|
||||
|
||||
def test_finds_config_within_depth_limit(self, tmp_path: Path) -> None:
|
||||
nested = tmp_path
|
||||
for i in range(WALK_MAX_DEPTH):
|
||||
nested = nested / f"level{i}"
|
||||
(nested / ".vibe" / "skills").mkdir(parents=True)
|
||||
_, skills, _ = walk_local_config_dirs_all(tmp_path)
|
||||
assert nested / ".vibe" / "skills" in skills
|
||||
|
||||
def test_does_not_find_config_beyond_depth_limit(self, tmp_path: Path) -> None:
|
||||
nested = tmp_path
|
||||
for i in range(WALK_MAX_DEPTH + 1):
|
||||
nested = nested / f"level{i}"
|
||||
(nested / ".vibe" / "tools").mkdir(parents=True)
|
||||
tools, skills, agents = walk_local_config_dirs_all(tmp_path)
|
||||
assert not tools
|
||||
assert not skills
|
||||
assert not agents
|
||||
|
||||
def test_respects_dir_count_limit(self, tmp_path: Path) -> None:
|
||||
# Create more directories than _MAX_DIRS at depth 1
|
||||
for i in range(_MAX_DIRS + 10):
|
||||
(tmp_path / f"dir{i:05d}").mkdir()
|
||||
# Place config in a directory that would be scanned late
|
||||
(tmp_path / "zzz_last" / ".vibe" / "tools").mkdir(parents=True)
|
||||
|
||||
tools, _, _ = walk_local_config_dirs_all(tmp_path)
|
||||
# The walk should stop before visiting all dirs.
|
||||
# Whether zzz_last is found depends on sort order and limit,
|
||||
# but total visited dirs should be bounded.
|
||||
# We just verify no crash and the function returns.
|
||||
assert isinstance(tools, tuple)
|
||||
|
||||
def test_skips_ignored_directories(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "node_modules" / ".vibe" / "tools").mkdir(parents=True)
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
tools, _, _ = walk_local_config_dirs_all(tmp_path)
|
||||
assert tools == (tmp_path / ".vibe" / "tools",)
|
||||
|
||||
def test_skips_dot_directories(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".hidden" / ".vibe" / "tools").mkdir(parents=True)
|
||||
tools, _, _ = walk_local_config_dirs_all(tmp_path)
|
||||
assert not tools
|
||||
|
||||
def test_preserves_alphabetical_ordering(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "bbb" / ".vibe" / "tools").mkdir(parents=True)
|
||||
(tmp_path / "aaa" / ".vibe" / "tools").mkdir(parents=True)
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
tools, _, _ = walk_local_config_dirs_all(tmp_path)
|
||||
assert tools == (
|
||||
tmp_path / ".vibe" / "tools",
|
||||
tmp_path / "aaa" / ".vibe" / "tools",
|
||||
tmp_path / "bbb" / ".vibe" / "tools",
|
||||
)
|
||||
|
||||
def test_finds_agents_skills(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".agents" / "skills").mkdir(parents=True)
|
||||
_, skills, _ = walk_local_config_dirs_all(tmp_path)
|
||||
assert tmp_path / ".agents" / "skills" in skills
|
||||
|
||||
def test_finds_all_config_types(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
|
||||
(tmp_path / ".vibe" / "agents").mkdir(parents=True)
|
||||
(tmp_path / ".agents" / "skills").mkdir(parents=True)
|
||||
tools, skills, agents = walk_local_config_dirs_all(tmp_path)
|
||||
assert tmp_path / ".vibe" / "tools" in tools
|
||||
assert tmp_path / ".vibe" / "skills" in skills
|
||||
assert tmp_path / ".vibe" / "agents" in agents
|
||||
assert tmp_path / ".agents" / "skills" in skills
|
||||
|
||||
|
||||
class TestHasConfigDirsNearby:
|
||||
def test_returns_true_when_vibe_tools_exist(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
assert has_config_dirs_nearby(tmp_path) is True
|
||||
|
||||
def test_returns_true_when_vibe_skills_exist(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
|
||||
assert has_config_dirs_nearby(tmp_path) is True
|
||||
|
||||
def test_returns_true_when_agents_skills_exist(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".agents" / "skills").mkdir(parents=True)
|
||||
assert has_config_dirs_nearby(tmp_path) is True
|
||||
|
||||
def test_returns_false_when_empty(self, tmp_path: Path) -> None:
|
||||
assert has_config_dirs_nearby(tmp_path) is False
|
||||
|
||||
def test_returns_false_for_vibe_dir_without_subdirs(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe").mkdir()
|
||||
assert has_config_dirs_nearby(tmp_path) is False
|
||||
|
||||
def test_returns_true_for_shallow_nested(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "sub" / ".vibe" / "skills").mkdir(parents=True)
|
||||
assert has_config_dirs_nearby(tmp_path) is True
|
||||
|
||||
def test_returns_true_at_depth_2(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "a" / "b" / ".agents" / "skills").mkdir(parents=True)
|
||||
assert has_config_dirs_nearby(tmp_path) is True
|
||||
|
||||
def test_returns_false_beyond_default_depth(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "a" / "b" / "c" / "d" / "e" / ".vibe" / "tools").mkdir(parents=True)
|
||||
assert has_config_dirs_nearby(tmp_path) is False
|
||||
|
||||
def test_custom_depth(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "a" / "b" / "c" / "d" / "e" / ".vibe" / "tools").mkdir(parents=True)
|
||||
assert has_config_dirs_nearby(tmp_path, max_depth=5) is True
|
||||
|
||||
def test_early_exit_on_first_match(self, tmp_path: Path) -> None:
|
||||
# Create many dirs but put config early; function should return quickly
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
for i in range(100):
|
||||
(tmp_path / f"dir{i}").mkdir()
|
||||
assert has_config_dirs_nearby(tmp_path) is True
|
||||
|
||||
def test_skips_ignored_directories(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True)
|
||||
assert has_config_dirs_nearby(tmp_path) is False
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.slug import _ADJECTIVES, _NOUNS, create_slug
|
||||
from vibe.core.utils.slug import _ADJECTIVES, _NOUNS, create_slug
|
||||
|
||||
|
||||
class TestCreateSlug:
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ import pytest
|
|||
from tests.conftest import build_test_vibe_config
|
||||
from tests.stubs.fake_tool import FakeTool, FakeToolArgs
|
||||
from vibe.core.agent_loop import ToolDecision, ToolExecutionResponse
|
||||
from vibe.core.config import Backend
|
||||
from vibe.core.llm.format import ResolvedToolCall
|
||||
from vibe.core.telemetry.send import DATALAKE_EVENTS_URL, TelemetryClient
|
||||
from vibe.core.tools.base import BaseTool, ToolPermission
|
||||
from vibe.core.types import Backend
|
||||
from vibe.core.utils import get_user_agent
|
||||
|
||||
_original_send_telemetry_event = TelemetryClient.send_telemetry_event
|
||||
|
|
@ -258,6 +258,8 @@ class TestTelemetryClient:
|
|||
nb_mcp_servers=1,
|
||||
nb_models=3,
|
||||
entrypoint="cli",
|
||||
client_name="vscode",
|
||||
client_version="1.96.0",
|
||||
terminal_emulator="vscode",
|
||||
)
|
||||
|
||||
|
|
@ -270,6 +272,8 @@ class TestTelemetryClient:
|
|||
assert properties["nb_mcp_servers"] == 1
|
||||
assert properties["nb_models"] == 3
|
||||
assert properties["entrypoint"] == "cli"
|
||||
assert properties["client_name"] == "vscode"
|
||||
assert properties["client_version"] == "1.96.0"
|
||||
assert properties["terminal_emulator"] == "vscode"
|
||||
assert "version" in properties
|
||||
|
||||
|
|
@ -372,3 +376,41 @@ class TestTelemetryClient:
|
|||
assert (
|
||||
calls[1].kwargs["json"]["properties"]["session_id"] == "second-session-id"
|
||||
)
|
||||
|
||||
def test_send_user_rating_feedback_payload(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
|
||||
client.send_user_rating_feedback(rating=2, model="mistral-large")
|
||||
|
||||
assert len(telemetry_events) == 1
|
||||
assert telemetry_events[0]["event_name"] == "vibe.user_rating_feedback"
|
||||
properties = telemetry_events[0]["properties"]
|
||||
assert properties["rating"] == 2
|
||||
assert properties["model"] == "mistral-large"
|
||||
assert "version" in properties
|
||||
|
||||
def test_send_user_rating_feedback_includes_correlation_id(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
client.last_correlation_id = "corr-abc-123"
|
||||
|
||||
client.send_user_rating_feedback(rating=1, model="mistral-large")
|
||||
|
||||
assert len(telemetry_events) == 1
|
||||
assert telemetry_events[0]["correlation_id"] == "corr-abc-123"
|
||||
|
||||
def test_send_user_rating_feedback_omits_correlation_id_when_none(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
|
||||
client.send_user_rating_feedback(rating=1, model="mistral-large")
|
||||
|
||||
assert len(telemetry_events) == 1
|
||||
assert "correlation_id" not in telemetry_events[0]
|
||||
|
|
|
|||
107
tests/core/test_tts_config.py
Normal file
107
tests/core/test_tts_config.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from vibe.core.config import (
|
||||
DEFAULT_TTS_MODELS,
|
||||
DEFAULT_TTS_PROVIDERS,
|
||||
TTSClient,
|
||||
TTSModelConfig,
|
||||
TTSProviderConfig,
|
||||
)
|
||||
|
||||
|
||||
class TestTTSConfigDefaults:
|
||||
def test_default_tts_providers_loaded(self) -> None:
|
||||
config = build_test_vibe_config()
|
||||
assert len(config.tts_providers) == len(DEFAULT_TTS_PROVIDERS)
|
||||
assert config.tts_providers[0].name == "mistral"
|
||||
assert config.tts_providers[0].api_base == "https://api.mistral.ai"
|
||||
|
||||
def test_default_tts_models_loaded(self) -> None:
|
||||
config = build_test_vibe_config()
|
||||
assert len(config.tts_models) == len(DEFAULT_TTS_MODELS)
|
||||
assert config.tts_models[0].alias == "voxtral-tts"
|
||||
assert config.tts_models[0].name == "voxtral-mini-tts-latest"
|
||||
|
||||
def test_default_active_tts_model(self) -> None:
|
||||
config = build_test_vibe_config()
|
||||
assert config.active_tts_model == "voxtral-tts"
|
||||
|
||||
|
||||
class TestGetActiveTTSModel:
|
||||
def test_resolves_by_alias(self) -> None:
|
||||
config = build_test_vibe_config()
|
||||
model = config.get_active_tts_model()
|
||||
assert model.alias == "voxtral-tts"
|
||||
assert model.name == "voxtral-mini-tts-latest"
|
||||
|
||||
def test_raises_for_unknown_alias(self) -> None:
|
||||
config = build_test_vibe_config(active_tts_model="nonexistent")
|
||||
with pytest.raises(ValueError, match="not found in configuration"):
|
||||
config.get_active_tts_model()
|
||||
|
||||
|
||||
class TestGetTTSProviderForModel:
|
||||
def test_resolves_by_name(self) -> None:
|
||||
config = build_test_vibe_config()
|
||||
model = config.get_active_tts_model()
|
||||
provider = config.get_tts_provider_for_model(model)
|
||||
assert provider.name == "mistral"
|
||||
assert provider.api_base == "https://api.mistral.ai"
|
||||
|
||||
def test_raises_for_unknown_provider(self) -> None:
|
||||
config = build_test_vibe_config(
|
||||
tts_models=[
|
||||
TTSModelConfig(name="test-model", provider="nonexistent", alias="test")
|
||||
],
|
||||
active_tts_model="test",
|
||||
)
|
||||
model = config.get_active_tts_model()
|
||||
with pytest.raises(ValueError, match="not found in configuration"):
|
||||
config.get_tts_provider_for_model(model)
|
||||
|
||||
|
||||
class TestTTSModelUniqueness:
|
||||
def test_duplicate_aliases_raise(self) -> None:
|
||||
with pytest.raises(ValueError, match="Duplicate TTS model alias"):
|
||||
build_test_vibe_config(
|
||||
tts_models=[
|
||||
TTSModelConfig(
|
||||
name="model-a", provider="mistral", alias="same-alias"
|
||||
),
|
||||
TTSModelConfig(
|
||||
name="model-b", provider="mistral", alias="same-alias"
|
||||
),
|
||||
],
|
||||
active_tts_model="same-alias",
|
||||
)
|
||||
|
||||
|
||||
class TestTTSModelConfig:
|
||||
def test_alias_defaults_to_name(self) -> None:
|
||||
model = TTSModelConfig.model_validate({
|
||||
"name": "my-model",
|
||||
"provider": "mistral",
|
||||
})
|
||||
assert model.alias == "my-model"
|
||||
|
||||
def test_explicit_alias(self) -> None:
|
||||
model = TTSModelConfig(
|
||||
name="my-model", provider="mistral", alias="custom-alias"
|
||||
)
|
||||
assert model.alias == "custom-alias"
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
model = TTSModelConfig(name="my-model", provider="mistral", alias="my-model")
|
||||
assert model.voice == "gb_jane_neutral"
|
||||
assert model.response_format == "wav"
|
||||
|
||||
|
||||
class TestTTSProviderConfig:
|
||||
def test_default_values(self) -> None:
|
||||
provider = TTSProviderConfig(name="test")
|
||||
assert provider.api_base == "https://api.mistral.ai"
|
||||
assert provider.api_key_env_var == ""
|
||||
assert provider.client == TTSClient.MISTRAL
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.core.utils import get_server_url_from_api_base
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -17,3 +20,45 @@ from vibe.core.utils import get_server_url_from_api_base
|
|||
)
|
||||
def test_get_server_url_from_api_base(api_base, expected):
|
||||
assert get_server_url_from_api_base(api_base) == expected
|
||||
|
||||
|
||||
class TestReadSafe:
|
||||
def test_reads_utf8(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "hello.txt"
|
||||
f.write_text("café\n", encoding="utf-8")
|
||||
assert read_safe(f) == "café\n"
|
||||
|
||||
def test_falls_back_on_non_utf8(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "latin.txt"
|
||||
# \x81 invalid UTF-8 and undefined in CP1252 → U+FFFD on all platforms
|
||||
f.write_bytes(b"maf\x81\n")
|
||||
result = read_safe(f)
|
||||
assert result == "maf<EFBFBD>\n"
|
||||
|
||||
def test_raise_on_error_true_utf8_succeeds(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "hello.txt"
|
||||
f.write_text("café\n", encoding="utf-8")
|
||||
assert read_safe(f, raise_on_error=True) == "café\n"
|
||||
|
||||
def test_raise_on_error_true_non_utf8_raises(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "bad.txt"
|
||||
# Invalid UTF-8; with raise_on_error=True we use default encoding (strict), so decode errors propagate
|
||||
f.write_bytes(b"maf\x81\n")
|
||||
assert read_safe(f, raise_on_error=False) == "maf<EFBFBD>\n"
|
||||
with pytest.raises(UnicodeDecodeError):
|
||||
read_safe(f, raise_on_error=True)
|
||||
|
||||
def test_empty_file(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "empty.txt"
|
||||
f.write_bytes(b"")
|
||||
assert read_safe(f) == ""
|
||||
|
||||
def test_binary_garbage_does_not_raise(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "garbage.bin"
|
||||
f.write_bytes(bytes(range(256)))
|
||||
result = read_safe(f)
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_file_not_found_raises(self, tmp_path: Path) -> None:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
read_safe(tmp_path / "nope.txt")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue