참고소스 수정본

This commit is contained in:
LASTA_DEV01\lasta
2026-05-12 19:40:31 +09:00
parent 0f34a451fc
commit 2e9204243d
8708 changed files with 3259488 additions and 869 deletions

View File

@@ -0,0 +1,805 @@
import asyncio
import json
import os
from dataclasses import dataclass
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from httpx import HTTPStatusError, Request, Response
from guardrails.api_client import GuardrailsApiClient
from guardrails.errors import ValidationError
from guardrails_ai.types import Guard, ValidationOutcome as IValidationOutcome
# ─── Helpers ──────────────────────────────────────────────────────────────────
def make_http_error(status_code: int) -> HTTPStatusError:
request = Request("POST", "http://test.com")
response = Response(status_code=status_code, request=request)
return HTTPStatusError(
message=f"HTTP {status_code}", request=request, response=response
)
def mock_sync_response(json_data=None, raise_error=None):
r = Mock()
r.json.return_value = json_data if json_data is not None else {}
r.status_code = 200
r.is_success = True
if raise_error:
r.raise_for_status.side_effect = raise_error
return r
def mock_async_response(json_data=None, raise_error=None):
# Use Mock (not AsyncMock) — httpx response methods are synchronous
r = Mock()
r.json.return_value = json_data if json_data is not None else {}
r.status_code = 200
r.is_success = True
if raise_error:
r.raise_for_status.side_effect = raise_error
return r
@dataclass
class MockedClient:
client: GuardrailsApiClient
http: MagicMock
ahttp: AsyncMock
def make_client(**kwargs) -> MockedClient:
"""Create a GuardrailsApiClient with mocked HTTP clients."""
with (
patch("guardrails.api_client.Client"),
patch("guardrails.api_client.AsyncClient"),
):
client = GuardrailsApiClient(**kwargs)
http = MagicMock()
ahttp = AsyncMock()
client.http_client = http # type: ignore[assignment]
client.ahttp_client = ahttp # type: ignore[assignment]
return MockedClient(client=client, http=http, ahttp=ahttp)
def make_guard(guard_id="guard-id-123", name="test-guard") -> Mock:
guard = Mock(spec=Guard)
guard.id = guard_id
guard.name = name
guard.model_dump.return_value = {"id": guard_id, "name": name}
return guard
def make_sse_chunk(data: dict) -> str:
return f"{json.dumps(data)}\n"
def make_stream_ctx(chunks, is_success=True, raise_on_fail=None):
"""Sync context manager mock for http_client.stream()."""
mock_resp = MagicMock()
mock_resp.is_success = is_success
mock_resp.iter_text.return_value = iter(chunks)
if raise_on_fail:
mock_resp.raise_for_status.side_effect = raise_on_fail
ctx = MagicMock()
ctx.__enter__ = Mock(return_value=mock_resp)
ctx.__exit__ = Mock(return_value=False)
return ctx
def make_async_stream_ctx(chunks, is_success=True, raise_on_fail=None):
"""Async context manager mock for ahttp_client.stream()."""
async def aiter():
for chunk in chunks:
yield chunk
mock_resp = MagicMock()
mock_resp.is_success = is_success
mock_resp.aiter_text.return_value = aiter()
if raise_on_fail:
mock_resp.raise_for_status.side_effect = raise_on_fail
ctx = AsyncMock()
ctx.__aenter__ = AsyncMock(return_value=mock_resp)
ctx.__aexit__ = AsyncMock(return_value=False)
return ctx
# ─── Init ─────────────────────────────────────────────────────────────────────
class TestGuardrailsApiClientInit:
def test_init_with_env_vars(self):
with (
patch("guardrails.api_client.Client"),
patch("guardrails.api_client.AsyncClient"),
):
with patch.dict(
os.environ,
{
"GUARDRAILS_BASE_URL": "http://env.com",
"GUARDRAILS_API_KEY": "env-key",
},
):
client = GuardrailsApiClient()
assert client.base_url == "http://env.com"
assert client.api_key == "env-key"
assert client.timeout == 300
def test_init_with_explicit_params(self):
with (
patch("guardrails.api_client.Client"),
patch("guardrails.api_client.AsyncClient"),
):
client = GuardrailsApiClient(
base_url="http://custom.com", api_key="custom-key"
)
assert client.base_url == "http://custom.com"
assert client.api_key == "custom-key"
assert client.timeout == 300
def test_init_default_values(self):
env = {
k: v
for k, v in os.environ.items()
if k not in ("GUARDRAILS_BASE_URL", "GUARDRAILS_API_KEY")
}
with (
patch("guardrails.api_client.Client"),
patch("guardrails.api_client.AsyncClient"),
):
with patch.dict(os.environ, env, clear=True):
client = GuardrailsApiClient()
assert client.base_url == "http://localhost:8000"
assert client.api_key == "x-guardrailsai-api-key"
def test_init_explicit_params_override_env(self):
with (
patch("guardrails.api_client.Client"),
patch("guardrails.api_client.AsyncClient"),
):
with patch.dict(
os.environ,
{
"GUARDRAILS_BASE_URL": "http://env.com",
"GUARDRAILS_API_KEY": "env-key",
},
):
client = GuardrailsApiClient(
base_url="http://custom.com", api_key="custom-key"
)
assert client.base_url == "http://custom.com"
assert client.api_key == "custom-key"
def test_init_partial_params_uses_env_key(self):
env = {k: v for k, v in os.environ.items() if k != "GUARDRAILS_API_KEY"}
with (
patch("guardrails.api_client.Client"),
patch("guardrails.api_client.AsyncClient"),
):
with patch.dict(
os.environ,
{**env, "GUARDRAILS_API_KEY": "env-key"},
clear=True,
):
client = GuardrailsApiClient(base_url="http://custom.com")
assert client.base_url == "http://custom.com"
assert client.api_key == "env-key"
def test_init_creates_http_clients(self):
client = GuardrailsApiClient()
assert client.http_client is not None
assert client.ahttp_client is not None
# ─── upsert_guard ─────────────────────────────────────────────────────────────
class TestUpsertGuard:
def test_upsert_guard_updates_existing_guard(self):
mc = make_client()
guard = make_guard()
guard_data = guard.model_dump.return_value
existing_guard = make_guard(guard_id="existing-id-456")
returned_guard = make_guard()
mc.client.fetch_guard = Mock(return_value=existing_guard)
mc.http.put.return_value = mock_sync_response(guard_data)
with patch.object(Guard, "model_validate", return_value=returned_guard):
result = mc.client.upsert_guard(guard)
mc.client.fetch_guard.assert_called_once_with(guard.name)
mc.http.put.assert_called_once_with("/guards/existing-id-456", json=guard_data)
mc.http.put.return_value.raise_for_status.assert_called_once()
mc.http.post.assert_not_called()
assert result == returned_guard
def test_upsert_guard_creates_new_guard_when_not_found(self):
mc = make_client()
guard = make_guard()
guard_data = guard.model_dump.return_value
returned_guard = make_guard()
mc.client.fetch_guard = Mock(return_value=None)
mc.http.post.return_value = mock_sync_response(guard_data)
with patch.object(Guard, "model_validate", return_value=returned_guard):
result = mc.client.upsert_guard(guard)
mc.client.fetch_guard.assert_called_once_with(guard.name)
mc.http.post.assert_called_once_with("/guards", json=guard_data)
mc.http.put.assert_not_called()
assert result == returned_guard
def test_upsert_guard_raises_on_http_error(self):
mc = make_client()
guard = make_guard()
guard.model_dump.return_value = {}
existing_guard = make_guard(guard_id="existing-id-456")
mc.client.fetch_guard = Mock(return_value=existing_guard)
mc.http.put.return_value = mock_sync_response(raise_error=make_http_error(500))
with pytest.raises(HTTPStatusError):
mc.client.upsert_guard(guard)
def test_aupsert_guard_updates_existing_guard(self):
mc = make_client()
guard = make_guard()
guard_data = guard.model_dump.return_value
existing_guard = make_guard(guard_id="existing-id-456")
returned_guard = make_guard()
mc.client.afetch_guard = AsyncMock(return_value=existing_guard)
mc.ahttp.put = AsyncMock(return_value=mock_async_response(guard_data))
with patch.object(Guard, "model_validate", return_value=returned_guard):
result = asyncio.run(mc.client.aupsert_guard(guard))
mc.client.afetch_guard.assert_called_once_with(guard.name)
mc.ahttp.put.assert_called_once_with("/guards/existing-id-456", json=guard_data)
mc.ahttp.post.assert_not_called()
assert result == returned_guard
def test_aupsert_guard_creates_new_guard_when_not_found(self):
mc = make_client()
guard = make_guard()
guard_data = guard.model_dump.return_value
returned_guard = make_guard()
mc.client.afetch_guard = AsyncMock(return_value=None)
mc.ahttp.post = AsyncMock(return_value=mock_async_response(guard_data))
with patch.object(Guard, "model_validate", return_value=returned_guard):
result = asyncio.run(mc.client.aupsert_guard(guard))
mc.client.afetch_guard.assert_called_once_with(guard.name)
mc.ahttp.post.assert_called_once_with("/guards", json=guard_data)
mc.ahttp.put.assert_not_called()
assert result == returned_guard
def test_aupsert_guard_raises_on_http_error(self):
mc = make_client()
guard = make_guard()
guard.model_dump.return_value = {}
existing_guard = make_guard(guard_id="existing-id-456")
mc.client.afetch_guard = AsyncMock(return_value=existing_guard)
mc.ahttp.put = AsyncMock(
return_value=mock_async_response(raise_error=make_http_error(500))
)
with pytest.raises(HTTPStatusError):
asyncio.run(mc.client.aupsert_guard(guard))
# ─── fetch_guard ──────────────────────────────────────────────────────────────
class TestFetchGuard:
def test_fetch_guard_success(self):
mc = make_client()
guard_data = {"id": "g1", "name": "my-guard"}
expected = make_guard()
mc.http.get.return_value = mock_sync_response([guard_data])
with patch.object(Guard, "model_validate", return_value=expected):
result = mc.client.fetch_guard("my-guard")
mc.http.get.assert_called_once_with("/guards?name=my-guard")
assert result == expected
def test_fetch_guard_returns_none_on_empty_list(self):
mc = make_client()
mc.http.get.return_value = mock_sync_response([])
result = mc.client.fetch_guard("missing-guard")
assert result is None
def test_fetch_guard_returns_none_on_exception(self):
mc = make_client()
mc.http.get.side_effect = Exception("connection error")
result = mc.client.fetch_guard("my-guard")
assert result is None
def test_fetch_guard_logs_error_on_exception(self):
mc = make_client()
mc.http.get.side_effect = Exception("API Error")
with patch("guardrails.api_client.logger") as mock_logger:
result = mc.client.fetch_guard("my-guard")
assert result is None
mock_logger.error.assert_called_once()
assert "Error fetching guard my-guard" in mock_logger.error.call_args[0][0]
assert "API Error" in mock_logger.error.call_args[0][0]
def test_afetch_guard_success(self):
mc = make_client()
guard_data = {"id": "g1", "name": "my-guard"}
expected = make_guard()
mc.ahttp.get = AsyncMock(return_value=mock_async_response([guard_data]))
with patch.object(Guard, "model_validate", return_value=expected):
result = asyncio.run(mc.client.afetch_guard("my-guard"))
mc.ahttp.get.assert_called_once_with("/guards?name=my-guard")
assert result == expected
def test_afetch_guard_returns_none_on_empty_list(self):
mc = make_client()
mc.ahttp.get = AsyncMock(return_value=mock_async_response([]))
result = asyncio.run(mc.client.afetch_guard("missing-guard"))
assert result is None
def test_afetch_guard_returns_none_on_exception(self):
mc = make_client()
mc.ahttp.get = AsyncMock(side_effect=Exception("connection error"))
result = asyncio.run(mc.client.afetch_guard("my-guard"))
assert result is None
def test_afetch_guard_logs_error_on_exception(self):
mc = make_client()
mc.ahttp.get = AsyncMock(side_effect=Exception("API Error"))
with patch("guardrails.api_client.logger") as mock_logger:
result = asyncio.run(mc.client.afetch_guard("my-guard"))
assert result is None
mock_logger.error.assert_called_once()
assert "Error fetching guard my-guard" in mock_logger.error.call_args[0][0]
# ─── delete_guard ─────────────────────────────────────────────────────────────
class TestDeleteGuard:
def test_delete_guard_success(self):
mc = make_client()
guard_data = {"id": "g1", "name": "my-guard"}
fetched_guard = make_guard(guard_id="g1")
deleted_guard = make_guard(guard_id="g1")
mc.http.get.return_value = mock_sync_response([guard_data])
mc.http.delete.return_value = mock_sync_response(guard_data)
with patch.object(
Guard, "model_validate", side_effect=[fetched_guard, deleted_guard]
):
result = mc.client.delete_guard("my-guard")
mc.http.delete.assert_called_once_with("/guards/g1")
assert result == deleted_guard
def test_delete_guard_does_nothing_when_guard_not_found(self):
mc = make_client()
mc.http.get.return_value = mock_sync_response([])
result = mc.client.delete_guard("missing-guard")
mc.http.delete.assert_not_called()
assert result is None
def test_delete_guard_does_nothing_when_guard_has_no_id(self):
mc = make_client()
guard_data = {"name": "my-guard"}
guard_no_id = make_guard()
guard_no_id.id = None
mc.http.get.return_value = mock_sync_response([guard_data])
with patch.object(Guard, "model_validate", return_value=guard_no_id):
result = mc.client.delete_guard("my-guard")
mc.http.delete.assert_not_called()
assert result is None
def test_adelete_guard_success(self):
mc = make_client()
guard_data = {"id": "g1", "name": "my-guard"}
fetched_guard = make_guard(guard_id="g1")
deleted_guard = make_guard(guard_id="g1")
mc.ahttp.get = AsyncMock(return_value=mock_async_response([guard_data]))
mc.ahttp.delete = AsyncMock(return_value=mock_async_response(guard_data))
with patch.object(
Guard, "model_validate", side_effect=[fetched_guard, deleted_guard]
):
result = asyncio.run(mc.client.adelete_guard("my-guard"))
mc.ahttp.delete.assert_called_once_with("/guards/g1")
assert result == deleted_guard
def test_adelete_guard_does_nothing_when_guard_not_found(self):
mc = make_client()
mc.ahttp.get = AsyncMock(return_value=mock_async_response([]))
result = asyncio.run(mc.client.adelete_guard("missing-guard"))
mc.ahttp.delete.assert_not_called()
assert result is None
# ─── validate ─────────────────────────────────────────────────────────────────
class TestValidate:
def test_validate_success(self):
mc = make_client()
guard = make_guard()
outcome_data = {"callId": "c1"}
outcome = Mock(spec=IValidationOutcome)
mc.http.post.return_value = mock_sync_response(outcome_data)
with patch.object(IValidationOutcome, "model_validate", return_value=outcome):
result = mc.client.validate(
guard,
openai_api_key="oai-key",
llm_output="hello",
num_reasks=1,
prompt_params={"key": "val"},
)
assert result == outcome
mc.http.post.assert_called_once_with(
"/guards/guard-id-123/validate",
json={
"llm_output": "hello",
"num_reasks": 1,
"prompt_params": {"key": "val"},
},
headers={"x-openai-api-key": "oai-key"},
)
def test_validate_uses_env_openai_key(self):
mc = make_client()
guard = make_guard()
mc.http.post.return_value = mock_sync_response({})
with patch.dict(os.environ, {"OPENAI_API_KEY": "env-oai-key"}):
with patch.object(
IValidationOutcome, "model_validate", return_value=Mock()
):
mc.client.validate(guard)
call_kwargs = mc.http.post.call_args[1]
assert call_kwargs["headers"] == {"x-openai-api-key": "env-oai-key"}
def test_validate_omits_openai_header_when_no_key(self):
mc = make_client()
guard = make_guard()
mc.http.post.return_value = mock_sync_response({})
env = {k: v for k, v in os.environ.items() if k != "OPENAI_API_KEY"}
with patch.dict(os.environ, env, clear=True):
with patch.object(
IValidationOutcome, "model_validate", return_value=Mock()
):
mc.client.validate(guard)
call_kwargs = mc.http.post.call_args[1]
assert call_kwargs["headers"] == {}
def test_validate_raises_validation_error_on_400(self):
mc = make_client()
guard = make_guard()
mc.http.post.return_value = mock_sync_response(raise_error=make_http_error(400))
with pytest.raises(ValidationError):
mc.client.validate(guard)
def test_validate_returns_none_on_non_400_http_error(self):
# Non-400 HTTPStatusErrors are caught but not re-raised per current impl
mc = make_client()
guard = make_guard()
mc.http.post.return_value = mock_sync_response(raise_error=make_http_error(500))
result = mc.client.validate(guard)
assert result is None
def test_validate_passes_kwargs_in_body(self):
mc = make_client()
guard = make_guard()
mc.http.post.return_value = mock_sync_response({})
with patch.object(IValidationOutcome, "model_validate", return_value=Mock()):
mc.client.validate(guard, extra_field="extra_value")
call_json = mc.http.post.call_args[1]["json"]
assert call_json["extra_field"] == "extra_value"
def test_avalidate_success(self):
mc = make_client()
guard = make_guard()
outcome_data = {"callId": "c1"}
outcome = Mock(spec=IValidationOutcome)
mc.ahttp.post = AsyncMock(return_value=mock_async_response(outcome_data))
with patch.object(IValidationOutcome, "model_validate", return_value=outcome):
result = asyncio.run(
mc.client.avalidate(
guard,
openai_api_key="oai-key",
llm_output="hello",
num_reasks=2,
)
)
assert result == outcome
mc.ahttp.post.assert_called_once_with(
"/guards/guard-id-123/validate",
json={
"llm_output": "hello",
"num_reasks": 2,
"prompt_params": None,
},
headers={"x-openai-api-key": "oai-key"},
)
def test_avalidate_raises_validation_error_on_400(self):
mc = make_client()
guard = make_guard()
mc.ahttp.post = AsyncMock(
return_value=mock_async_response(raise_error=make_http_error(400))
)
with pytest.raises(ValidationError):
asyncio.run(mc.client.avalidate(guard))
def test_avalidate_returns_none_on_non_400_http_error(self):
mc = make_client()
guard = make_guard()
mc.ahttp.post = AsyncMock(
return_value=mock_async_response(raise_error=make_http_error(500))
)
result = asyncio.run(mc.client.avalidate(guard))
assert result is None
# ─── stream_validate ──────────────────────────────────────────────────────────
class TestStreamValidate:
def test_stream_validate_yields_outcomes(self):
mc = make_client()
guard = make_guard()
outcome = Mock(spec=IValidationOutcome)
chunks = [
make_sse_chunk({"callId": "c1", "validationPassed": True}),
make_sse_chunk({"callId": "c2", "validationPassed": False}),
]
mc.http.stream.return_value = make_stream_ctx(chunks)
with patch.object(IValidationOutcome, "model_validate", return_value=outcome):
results = list(mc.client.stream_validate(guard, openai_api_key="oai-key"))
assert len(results) == 2
assert all(r is outcome for r in results)
call_args = mc.http.stream.call_args
assert call_args[0] == ("POST", "/guards/guard-id-123/validate")
assert call_args[1]["json"]["stream"] is True
def test_stream_validate_skips_empty_chunks(self):
mc = make_client()
guard = make_guard()
outcome = Mock(spec=IValidationOutcome)
chunks = ["", " ", make_sse_chunk({"callId": "c1"}), ""]
mc.http.stream.return_value = make_stream_ctx(chunks)
with patch.object(IValidationOutcome, "model_validate", return_value=outcome):
results = list(mc.client.stream_validate(guard))
assert len(results) == 1
def test_stream_validate_raises_on_error_in_chunk(self):
mc = make_client()
guard = make_guard()
chunks = [make_sse_chunk({"error": {"message": "Validation error occurred"}})]
mc.http.stream.return_value = make_stream_ctx(chunks)
with pytest.raises(Exception, match="Validation error occurred"):
list(mc.client.stream_validate(guard))
def test_stream_validate_raises_on_non_success_response(self):
mc = make_client()
guard = make_guard()
ctx = make_stream_ctx([], is_success=False, raise_on_fail=make_http_error(500))
mc.http.stream.return_value = ctx
with pytest.raises(HTTPStatusError):
list(mc.client.stream_validate(guard))
def test_stream_validate_passes_llm_output_and_params(self):
mc = make_client()
guard = make_guard()
mc.http.stream.return_value = make_stream_ctx([])
list(
mc.client.stream_validate(
guard,
llm_output="test output",
num_reasks=3,
prompt_params={"k": "v"},
)
)
call_json = mc.http.stream.call_args[1]["json"]
assert call_json["llm_output"] == "test output"
assert call_json["num_reasks"] == 3
assert call_json["prompt_params"] == {"k": "v"}
assert call_json["stream"] is True
def test_stream_validate_uses_env_openai_key(self):
mc = make_client()
guard = make_guard()
mc.http.stream.return_value = make_stream_ctx([])
with patch.dict(os.environ, {"OPENAI_API_KEY": "env-oai-key"}):
list(mc.client.stream_validate(guard))
mc.http.stream.assert_called_once()
def test_astream_validate_yields_outcomes(self):
mc = make_client()
guard = make_guard()
outcome = Mock(spec=IValidationOutcome)
chunks = [
make_sse_chunk({"callId": "c1"}),
make_sse_chunk({"callId": "c2"}),
]
# ahttp is AsyncMock, so .stream() returns a coroutine by default.
# Override with a regular Mock so it returns the async context manager.
mock_stream = Mock(return_value=make_async_stream_ctx(chunks))
mc.ahttp.stream = mock_stream
async def run():
return [
r
async for r in mc.client.astream_validate(
guard, openai_api_key="oai-key"
)
]
with patch.object(IValidationOutcome, "model_validate", return_value=outcome):
results = asyncio.run(run())
assert len(results) == 2
assert all(r is outcome for r in results)
call_args = mock_stream.call_args
assert call_args[0] == ("POST", "/guards/guard-id-123/validate")
assert call_args[1]["json"]["stream"] is True
def test_astream_validate_skips_empty_chunks(self):
mc = make_client()
guard = make_guard()
outcome = Mock(spec=IValidationOutcome)
chunks = ["", make_sse_chunk({"callId": "c1"}), " "]
mc.ahttp.stream = Mock(return_value=make_async_stream_ctx(chunks))
async def run():
return [r async for r in mc.client.astream_validate(guard)]
with patch.object(IValidationOutcome, "model_validate", return_value=outcome):
results = asyncio.run(run())
assert len(results) == 1
def test_astream_validate_raises_on_error_in_chunk(self):
mc = make_client()
guard = make_guard()
chunks = [make_sse_chunk({"error": {"message": "Stream error"}})]
mc.ahttp.stream = Mock(return_value=make_async_stream_ctx(chunks))
async def run():
return [r async for r in mc.client.astream_validate(guard)]
with pytest.raises(Exception, match="Stream error"):
asyncio.run(run())
def test_astream_validate_raises_on_non_success_response(self):
mc = make_client()
guard = make_guard()
ctx = make_async_stream_ctx(
[], is_success=False, raise_on_fail=make_http_error(500)
)
mc.ahttp.stream = Mock(return_value=ctx)
async def run():
return [r async for r in mc.client.astream_validate(guard)]
with pytest.raises(HTTPStatusError):
asyncio.run(run())
# ─── get_history ──────────────────────────────────────────────────────────────
class TestGetHistory:
def test_get_history_success(self):
mc = make_client()
history_data = {"calls": [{"callId": "c1"}]}
mc.http.get.return_value = mock_sync_response(history_data)
result = mc.client.get_history("g1", "c1")
mc.http.get.assert_called_once_with("/guards/g1/history/c1")
assert result == history_data
def test_get_history_uses_guard_id_not_name(self):
mc = make_client()
mc.http.get.return_value = mock_sync_response({})
mc.client.get_history("guard-uuid-123", "call-uuid-456")
mc.http.get.assert_called_once_with(
"/guards/guard-uuid-123/history/call-uuid-456"
)
def test_get_history_raises_on_http_error(self):
mc = make_client()
mc.http.get.return_value = mock_sync_response(raise_error=make_http_error(404))
with pytest.raises(HTTPStatusError):
mc.client.get_history("g1", "c1")
def test_get_history_returns_raw_body(self):
mc = make_client()
raw_body = [{"id": "1"}, {"id": "2"}]
mc.http.get.return_value = mock_sync_response(raw_body)
result = mc.client.get_history("g1", "c1")
assert result == raw_body