Files
2026-07-13 13:22:34 +08:00

411 lines
12 KiB
Python

import pytest
from fastapi import HTTPException
from mlflow.exceptions import MlflowException
from mlflow.gateway.exceptions import AIGatewayException
from mlflow.gateway.utils import (
SearchRoutesToken,
_is_valid_uri,
assemble_uri_path,
check_configuration_route_name_collisions,
get_gateway_uri,
handle_incomplete_chunks,
is_valid_endpoint_name,
parse_sse_lines,
resolve_route_url,
safe_stream,
set_gateway_uri,
stream_sse_data,
to_sse_error_chunk,
translate_http_exception,
)
from mlflow.protos.databricks_pb2 import INVALID_PARAMETER_VALUE
@pytest.mark.parametrize(
("base_url", "route"),
[
("http://127.0.0.1:6000", "gateway/test/invocations"),
("http://127.0.0.1:6000/", "/gateway/test/invocations"),
("http://127.0.0.1:6000/gateway", "/test/invocations"),
("http://127.0.0.1:6000/gateway/", "/test/invocations"),
("http://127.0.0.1:6000/gateway", "test/invocations"),
("http://127.0.0.1:6000/gateway/", "test/invocations"),
],
)
def test_resolve_route_url(base_url, route):
assert resolve_route_url(base_url, route) == "http://127.0.0.1:6000/gateway/test/invocations"
@pytest.mark.parametrize("base_url", ["databricks", "databricks://my.workspace"])
def test_resolve_route_url_qualified_url_ignores_base(base_url):
route = "https://my.databricks.workspace/api/2.0/gateway/chat/invocations"
resolved = resolve_route_url(base_url, route)
assert resolved == route
@pytest.mark.parametrize(
("name", "expected"),
[
("validName", True),
("valid-name", True),
("valid_name", True),
("valid.name", True),
("valid123", True),
("invalid name", False),
("invalid/name", False),
("invalid?name", False),
("", False),
("日本語", False), # Japanese characters
("naïve", False), # accented characters
("名前", False), # Chinese characters
],
)
def test_is_valid_endpoint_name(name, expected):
assert is_valid_endpoint_name(name) == expected
def test_check_configuration_route_name_collisions():
config = {"endpoints": [{"name": "name1"}, {"name": "name2"}, {"name": "name1"}]}
with pytest.raises(
MlflowException, match="Duplicate names found in endpoint / route configurations"
):
check_configuration_route_name_collisions(config)
@pytest.mark.parametrize(
("uri", "expected"),
[
("http://localhost", True),
("databricks", True),
("localhost", False),
("http:/localhost", False),
("", False),
],
)
def test__is_valid_uri(uri, expected):
assert _is_valid_uri(uri) == expected
@pytest.mark.parametrize(
("paths", "expected"),
[
(["path1", "path2", "path3"], "/path1/path2/path3"),
(["/path1/", "/path2/", "/path3/"], "/path1/path2/path3"),
(["/path1//", "/path2//", "/path3//"], "/path1/path2/path3"),
(["path1", "", "path3"], "/path1/path3"),
(["", "", ""], "/"),
([], "/"),
],
)
def test_assemble_uri_path(paths, expected):
assert assemble_uri_path(paths) == expected
def test_set_gateway_uri(monkeypatch):
monkeypatch.setattr("mlflow.gateway.utils._gateway_uri", None)
valid_uri = "http://localhost"
set_gateway_uri(valid_uri)
assert get_gateway_uri() == valid_uri
invalid_uri = "localhost"
with pytest.raises(MlflowException, match="The gateway uri provided is missing required"):
set_gateway_uri(invalid_uri)
def test_get_gateway_uri(monkeypatch):
monkeypatch.setattr("mlflow.gateway.utils._gateway_uri", None)
monkeypatch.delenv("MLFLOW_GATEWAY_URI", raising=False)
with pytest.raises(MlflowException, match="No Gateway server uri has been set"):
get_gateway_uri()
valid_uri = "http://localhost"
monkeypatch.setattr("mlflow.gateway.utils._gateway_uri", valid_uri)
assert get_gateway_uri() == valid_uri
monkeypatch.delenv("MLFLOW_GATEWAY_URI", raising=False)
set_gateway_uri(valid_uri)
assert get_gateway_uri() == valid_uri
def test_search_routes_token_decodes_correctly():
token = SearchRoutesToken(12345)
encoded_token = token.encode()
decoded_token = SearchRoutesToken.decode(encoded_token)
assert decoded_token.index == token.index
@pytest.mark.parametrize(
"index",
[
"not an integer",
-1,
None,
[1, 2, 3],
{"key": "value"},
],
)
def test_search_routes_token_with_invalid_token_values(index):
token = SearchRoutesToken(index)
encoded_token = token.encode()
with pytest.raises(MlflowException, match="Invalid SearchRoutes token"):
SearchRoutesToken.decode(encoded_token)
@pytest.mark.asyncio
async def test_translate_http_exception_handles_ai_gateway_exception():
@translate_http_exception
async def raise_ai_gateway_exception():
raise AIGatewayException(status_code=503, detail="AI Gateway error")
with pytest.raises(HTTPException, match="AI Gateway error") as exc_info:
await raise_ai_gateway_exception()
assert exc_info.value.status_code == 503
assert exc_info.value.detail == "AI Gateway error"
@pytest.mark.asyncio
async def test_translate_http_exception_handles_mlflow_exception():
@translate_http_exception
async def raise_mlflow_exception():
raise MlflowException("Invalid parameter", error_code=INVALID_PARAMETER_VALUE)
with pytest.raises(HTTPException, match="Invalid parameter") as exc_info:
await raise_mlflow_exception()
assert exc_info.value.status_code == 400
assert exc_info.value.detail == {
"error_code": "INVALID_PARAMETER_VALUE",
"message": "Invalid parameter",
}
@pytest.mark.asyncio
async def test_translate_http_exception_passes_through_other_exceptions():
@translate_http_exception
async def raise_value_error():
raise ValueError("Some value error")
with pytest.raises(ValueError, match="Some value error"):
await raise_value_error()
def test_parse_sse_lines_single_data_line():
chunk = b'data: {"message": "hello"}\n'
result = list(parse_sse_lines(chunk))
assert result == [{"message": "hello"}]
def test_parse_sse_lines_multiple_data_lines():
chunk = b'data: {"id": 1}\ndata: {"id": 2}\n'
result = list(parse_sse_lines(chunk))
assert result == [{"id": 1}, {"id": 2}]
def test_parse_sse_lines_with_event_lines():
chunk = b'event: message\ndata: {"content": "test"}\n'
result = list(parse_sse_lines(chunk))
assert result == [{"content": "test"}]
def test_parse_sse_lines_done_marker():
chunk = b"data: [DONE]\n"
result = list(parse_sse_lines(chunk))
assert result == []
def test_parse_sse_lines_empty_data():
chunk = b"data: \n"
result = list(parse_sse_lines(chunk))
assert result == []
def test_parse_sse_lines_empty_chunk():
result = list(parse_sse_lines(b""))
assert result == []
def test_parse_sse_lines_string_input():
chunk = 'data: {"key": "value"}\n'
result = list(parse_sse_lines(chunk))
assert result == [{"key": "value"}]
def test_parse_sse_lines_invalid_json():
chunk = b"data: {invalid json}\n"
result = list(parse_sse_lines(chunk))
assert result == []
def test_parse_sse_lines_mixed_valid_invalid():
chunk = b'data: {"valid": true}\ndata: invalid\ndata: {"also": "valid"}\n'
result = list(parse_sse_lines(chunk))
assert result == [{"valid": True}, {"also": "valid"}]
def test_parse_sse_lines_invalid_utf8():
chunk = b"\xff\xfe"
result = list(parse_sse_lines(chunk))
assert result == []
def test_parse_sse_lines_non_data_lines_ignored():
chunk = b'id: 123\nretry: 1000\ndata: {"message": "test"}\n'
result = list(parse_sse_lines(chunk))
assert result == [{"message": "test"}]
@pytest.mark.asyncio
async def test_stream_sse_data_yields_parsed_json():
async def mock_stream():
yield b'data: {"chunk": 1}\n'
yield b'data: {"chunk": 2}\n'
results = [data async for data in stream_sse_data(mock_stream())]
assert results == [{"chunk": 1}, {"chunk": 2}]
@pytest.mark.asyncio
async def test_stream_sse_data_skips_done():
async def mock_stream():
yield b'data: {"chunk": 1}\n'
yield b"data: [DONE]\n"
results = [data async for data in stream_sse_data(mock_stream())]
assert results == [{"chunk": 1}]
@pytest.mark.asyncio
async def test_stream_sse_data_skips_empty_lines():
async def mock_stream():
yield b""
yield b'data: {"chunk": 1}\n'
yield b" "
yield b'data: {"chunk": 2}\n'
results = [data async for data in stream_sse_data(mock_stream())]
assert results == [{"chunk": 1}, {"chunk": 2}]
@pytest.mark.asyncio
async def test_stream_sse_data_skips_invalid_json():
async def mock_stream():
yield b'data: {"valid": true}\n'
yield b"data: not json\n"
yield b'data: {"also_valid": true}\n'
results = [data async for data in stream_sse_data(mock_stream())]
assert results == [{"valid": True}, {"also_valid": True}]
@pytest.mark.asyncio
async def test_handle_incomplete_chunks_complete_lines():
async def mock_stream():
yield b"line1\nline2\n"
results = [chunk async for chunk in handle_incomplete_chunks(mock_stream())]
assert results == [b"line1", b"line2"]
@pytest.mark.asyncio
async def test_handle_incomplete_chunks_split_across_chunks():
async def mock_stream():
yield b"he"
yield b"llo\nwor"
yield b"ld\n"
results = [chunk async for chunk in handle_incomplete_chunks(mock_stream())]
assert results == [b"hello", b"world"]
@pytest.mark.asyncio
async def test_handle_incomplete_chunks_trailing_data():
async def mock_stream():
yield b"line1\nline2"
results = [chunk async for chunk in handle_incomplete_chunks(mock_stream())]
assert results == [b"line1", b"line2"]
@pytest.mark.asyncio
async def test_handle_incomplete_chunks_no_newline():
async def mock_stream():
yield b"no newline at all"
results = [chunk async for chunk in handle_incomplete_chunks(mock_stream())]
assert results == [b"no newline at all"]
def test_to_sse_error_chunk():
error = ValueError("Something went wrong")
result = to_sse_error_chunk(error)
assert (
result == 'data: {"error": {"message": "Something went wrong", "type": "ValueError"}}\n\n'
)
def test_to_sse_error_chunk_with_custom_exception():
class CustomError(Exception):
pass
error = CustomError("Custom error message")
result = to_sse_error_chunk(error)
assert (
result == 'data: {"error": {"message": "Custom error message", "type": "CustomError"}}\n\n'
)
@pytest.mark.asyncio
async def test_safe_stream_passes_through_chunks():
async def mock_stream():
yield "chunk1"
yield "chunk2"
yield "chunk3"
results = [chunk async for chunk in safe_stream(mock_stream())]
assert results == ["chunk1", "chunk2", "chunk3"]
@pytest.mark.asyncio
async def test_safe_stream_catches_exception_and_yields_error_chunk():
async def mock_stream():
yield "chunk1"
raise RuntimeError("Stream failed")
results = [chunk async for chunk in safe_stream(mock_stream())]
assert len(results) == 2
assert results[0] == "chunk1"
assert '"error"' in results[1]
assert '"message": "Stream failed"' in results[1]
assert '"type": "RuntimeError"' in results[1]
@pytest.mark.asyncio
async def test_safe_stream_as_bytes():
async def mock_stream():
yield b"chunk1"
raise ValueError("Bytes stream failed")
results = [chunk async for chunk in safe_stream(mock_stream(), as_bytes=True)]
assert len(results) == 2
assert results[0] == b"chunk1"
assert isinstance(results[1], bytes)
assert b'"error"' in results[1]
assert b'"message": "Bytes stream failed"' in results[1]
assert b'"type": "ValueError"' in results[1]
@pytest.mark.asyncio
async def test_safe_stream_no_exception():
async def mock_stream():
yield "data1"
yield "data2"
results = [chunk async for chunk in safe_stream(mock_stream())]
assert results == ["data1", "data2"]