mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-22 11:22:49 -05:00
921 lines
31 KiB
Python
921 lines
31 KiB
Python
"""
|
|
External dependency unit tests for Python tool.
|
|
|
|
These tests run against a real Code Interpreter service (no mocking of the service).
|
|
They verify code execution, error handling, timeout behavior, and file generation.
|
|
|
|
Requirements:
|
|
- CODE_INTERPRETER_BASE_URL must be configured and point to a running service
|
|
- Tests use minimal mocking - only mock run_context infrastructure and db lookups
|
|
- File store operations execute for real (files are saved and read back)
|
|
"""
|
|
|
|
import asyncio
|
|
import io
|
|
import json
|
|
from unittest.mock import Mock
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from agents import RunContextWrapper
|
|
from openpyxl import load_workbook
|
|
from pydantic import TypeAdapter
|
|
from sqlalchemy.orm import Session
|
|
|
|
from onyx.chat.turn.models import ChatTurnContext
|
|
from onyx.configs.app_configs import CODE_INTERPRETER_BASE_URL
|
|
from onyx.file_store.models import ChatFileType
|
|
from onyx.file_store.models import InMemoryChatFile
|
|
from onyx.file_store.utils import get_default_file_store
|
|
from onyx.server.query_and_chat.streaming_models import Packet
|
|
from onyx.server.query_and_chat.streaming_models import PythonToolDelta
|
|
from onyx.server.query_and_chat.streaming_models import PythonToolStart
|
|
from onyx.tools.tool_implementations.python.python_tool import PythonTool
|
|
from onyx.tools.tool_implementations_v2.code_interpreter_client import (
|
|
CodeInterpreterClient,
|
|
)
|
|
from onyx.tools.tool_implementations_v2.python import _python_execution_core
|
|
from onyx.tools.tool_implementations_v2.python import python
|
|
from onyx.tools.tool_implementations_v2.tool_result_models import (
|
|
LlmPythonExecutionResult,
|
|
)
|
|
|
|
|
|
# Apply initialize_file_store fixture to all tests in this module
|
|
pytestmark = pytest.mark.usefixtures("initialize_file_store")
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_run_context() -> RunContextWrapper[ChatTurnContext]:
|
|
"""Create a mock run context for testing."""
|
|
# Create mock emitter
|
|
mock_emitter = Mock()
|
|
mock_emitter.emit = Mock()
|
|
|
|
# Create mock run dependencies
|
|
mock_dependencies = Mock()
|
|
mock_dependencies.emitter = mock_emitter
|
|
mock_dependencies.db_session = Mock()
|
|
|
|
# Create mock context
|
|
mock_context = Mock(spec=ChatTurnContext)
|
|
mock_context.current_run_step = 0
|
|
mock_context.run_dependencies = mock_dependencies
|
|
mock_context.iteration_instructions = []
|
|
mock_context.global_iteration_responses = []
|
|
mock_context.chat_files = []
|
|
|
|
# Create run context wrapper
|
|
run_context = Mock(spec=RunContextWrapper)
|
|
run_context.context = mock_context
|
|
|
|
return run_context
|
|
|
|
|
|
@pytest.fixture
|
|
def code_interpreter_client() -> CodeInterpreterClient:
|
|
"""Create a real Code Interpreter client for testing."""
|
|
if not CODE_INTERPRETER_BASE_URL:
|
|
pytest.skip("CODE_INTERPRETER_BASE_URL not configured")
|
|
return CodeInterpreterClient()
|
|
|
|
|
|
def test_python_execution_basic(
|
|
mock_run_context: RunContextWrapper[ChatTurnContext],
|
|
code_interpreter_client: CodeInterpreterClient,
|
|
) -> None:
|
|
"""Test basic Python execution with simple code."""
|
|
code = 'print("Hello, World!")'
|
|
|
|
# Mock get_tool_by_name
|
|
with patch(
|
|
"onyx.tools.tool_implementations_v2.python.get_tool_by_name"
|
|
) as mock_get_tool:
|
|
mock_tool = Mock()
|
|
mock_tool.id = 1
|
|
mock_get_tool.return_value = mock_tool
|
|
|
|
# Execute code
|
|
result = _python_execution_core(mock_run_context, code, code_interpreter_client)
|
|
|
|
# Verify result
|
|
assert isinstance(result, LlmPythonExecutionResult)
|
|
assert "Hello, World!" in result.stdout
|
|
assert result.stderr == ""
|
|
assert result.exit_code == 0
|
|
assert not result.timed_out
|
|
assert len(result.generated_files) == 0
|
|
|
|
# Verify context was updated
|
|
# Note: @tool_accounting increments current_run_step from 0 to 1 before execution
|
|
assert len(mock_run_context.context.iteration_instructions) == 1
|
|
instruction = mock_run_context.context.iteration_instructions[0]
|
|
assert instruction.iteration_nr == 1
|
|
assert instruction.plan and "Python" in instruction.plan
|
|
|
|
assert len(mock_run_context.context.global_iteration_responses) == 1
|
|
answer = mock_run_context.context.global_iteration_responses[0]
|
|
assert answer.tool == "PythonTool"
|
|
assert "Hello, World!" in answer.answer
|
|
|
|
# Verify streaming packets were emitted
|
|
mock_emitter = mock_run_context.context.run_dependencies.emitter
|
|
emitter_calls = mock_emitter.emit.call_args_list # type: ignore
|
|
assert len(emitter_calls) >= 2 # At least start and delta
|
|
|
|
# Check for PythonToolStart packet
|
|
start_packets = [
|
|
call[0][0]
|
|
for call in emitter_calls
|
|
if isinstance(call[0][0].obj, PythonToolStart)
|
|
]
|
|
assert len(start_packets) == 1
|
|
|
|
# Check for PythonToolDelta packet
|
|
delta_packets = [
|
|
call[0][0]
|
|
for call in emitter_calls
|
|
if isinstance(call[0][0].obj, PythonToolDelta)
|
|
]
|
|
assert len(delta_packets) >= 1
|
|
assert "Hello, World!" in delta_packets[0].obj.stdout
|
|
|
|
|
|
def test_python_execution_with_syntax_error(
|
|
mock_run_context: RunContextWrapper[ChatTurnContext],
|
|
code_interpreter_client: CodeInterpreterClient,
|
|
) -> None:
|
|
"""Test Python execution with syntax error."""
|
|
code = "print('missing closing quote"
|
|
|
|
# Mock get_tool_by_name
|
|
with patch(
|
|
"onyx.tools.tool_implementations_v2.python.get_tool_by_name"
|
|
) as mock_get_tool:
|
|
mock_tool = Mock()
|
|
mock_tool.id = 1
|
|
mock_get_tool.return_value = mock_tool
|
|
|
|
# Execute code
|
|
result = _python_execution_core(mock_run_context, code, code_interpreter_client)
|
|
|
|
# Verify error result
|
|
assert isinstance(result, LlmPythonExecutionResult)
|
|
assert result.stdout == ""
|
|
assert len(result.stderr) > 0
|
|
assert "SyntaxError" in result.stderr or "unterminated" in result.stderr.lower()
|
|
assert result.exit_code != 0
|
|
assert not result.timed_out
|
|
assert result.error is not None or len(result.stderr) > 0
|
|
assert len(result.generated_files) == 0
|
|
|
|
|
|
def test_python_execution_with_runtime_error(
|
|
mock_run_context: RunContextWrapper[ChatTurnContext],
|
|
code_interpreter_client: CodeInterpreterClient,
|
|
) -> None:
|
|
"""Test Python execution with runtime error."""
|
|
code = """
|
|
x = 10
|
|
y = 0
|
|
result = x / y # Division by zero
|
|
print(result)
|
|
"""
|
|
|
|
# Mock get_tool_by_name
|
|
with patch(
|
|
"onyx.tools.tool_implementations_v2.python.get_tool_by_name"
|
|
) as mock_get_tool:
|
|
mock_tool = Mock()
|
|
mock_tool.id = 1
|
|
mock_get_tool.return_value = mock_tool
|
|
|
|
# Execute code
|
|
result = _python_execution_core(mock_run_context, code, code_interpreter_client)
|
|
|
|
# Verify error result
|
|
assert isinstance(result, LlmPythonExecutionResult)
|
|
assert result.exit_code != 0
|
|
assert "ZeroDivisionError" in result.stderr or "division" in result.stderr.lower()
|
|
assert result.error is not None or len(result.stderr) > 0
|
|
|
|
|
|
def test_python_execution_timeout(
|
|
mock_run_context: RunContextWrapper[ChatTurnContext],
|
|
) -> None:
|
|
"""Test execution timeout handling."""
|
|
# Code that will run longer than the timeout
|
|
code = """
|
|
import time
|
|
time.sleep(10)
|
|
print("Should not reach here")
|
|
"""
|
|
|
|
# Create client with short timeout (override via execute method)
|
|
if not CODE_INTERPRETER_BASE_URL:
|
|
pytest.skip("CODE_INTERPRETER_BASE_URL not configured")
|
|
|
|
client = CodeInterpreterClient()
|
|
|
|
# Mock get_tool_by_name
|
|
with patch(
|
|
"onyx.tools.tool_implementations_v2.python.get_tool_by_name"
|
|
) as mock_get_tool:
|
|
mock_tool = Mock()
|
|
mock_tool.id = 1
|
|
mock_get_tool.return_value = mock_tool
|
|
|
|
# Mock the config to use a short timeout
|
|
with patch(
|
|
"onyx.tools.tool_implementations_v2.python.CODE_INTERPRETER_DEFAULT_TIMEOUT_MS",
|
|
1000,
|
|
):
|
|
# Execute code
|
|
result = _python_execution_core(mock_run_context, code, client)
|
|
|
|
# Verify timeout result
|
|
assert isinstance(result, LlmPythonExecutionResult)
|
|
assert result.timed_out
|
|
|
|
|
|
def test_python_execution_file_generation(
|
|
mock_run_context: RunContextWrapper[ChatTurnContext],
|
|
code_interpreter_client: CodeInterpreterClient,
|
|
db_session: Session, # Needed to initialize DB engine for file_store
|
|
) -> None:
|
|
"""Test file generation and retrieval."""
|
|
code = """
|
|
import csv
|
|
|
|
# Create a CSV file
|
|
with open('test_output.csv', 'w', newline='') as f:
|
|
writer = csv.writer(f)
|
|
writer.writerow(['Name', 'Age', 'City'])
|
|
writer.writerow(['Alice', '30', 'New York'])
|
|
writer.writerow(['Bob', '25', 'San Francisco'])
|
|
|
|
print("CSV file created successfully")
|
|
"""
|
|
|
|
# Mock only get_tool_by_name (database lookup)
|
|
with patch(
|
|
"onyx.tools.tool_implementations_v2.python.get_tool_by_name"
|
|
) as mock_get_tool:
|
|
mock_tool = Mock()
|
|
mock_tool.id = 1
|
|
mock_get_tool.return_value = mock_tool
|
|
|
|
# Execute code - file store operations happen for real
|
|
result = _python_execution_core(mock_run_context, code, code_interpreter_client)
|
|
|
|
# Verify result
|
|
assert isinstance(result, LlmPythonExecutionResult)
|
|
assert result.exit_code == 0
|
|
assert "CSV file created successfully" in result.stdout
|
|
assert len(result.generated_files) == 1
|
|
|
|
# Verify file metadata
|
|
generated_file = result.generated_files[0]
|
|
assert generated_file.filename == "test_output.csv"
|
|
assert generated_file.file_link # File link exists
|
|
assert generated_file.file_link.startswith("http://localhost:3000/api/chat/file/")
|
|
|
|
# Extract file_id from file_link
|
|
file_id = generated_file.file_link.split("/")[-1]
|
|
|
|
# Verify we can read the file back from the file store
|
|
file_store = get_default_file_store()
|
|
file_io = file_store.read_file(file_id)
|
|
file_content = file_io.read()
|
|
|
|
# Verify file content
|
|
assert b"Name,Age,City" in file_content
|
|
assert b"Alice,30,New York" in file_content
|
|
assert b"Bob,25,San Francisco" in file_content
|
|
|
|
# Verify iteration answer includes file_ids
|
|
assert len(mock_run_context.context.global_iteration_responses) == 1
|
|
answer = mock_run_context.context.global_iteration_responses[0]
|
|
assert answer.file_ids == [file_id]
|
|
|
|
|
|
def test_python_execution_with_matplotlib(
|
|
mock_run_context: RunContextWrapper[ChatTurnContext],
|
|
code_interpreter_client: CodeInterpreterClient,
|
|
db_session: Session, # Needed to initialize DB engine for file_store
|
|
) -> None:
|
|
"""Test matplotlib plot generation."""
|
|
code = """
|
|
import matplotlib
|
|
matplotlib.use('Agg') # Use non-interactive backend
|
|
import matplotlib.pyplot as plt
|
|
import numpy as np
|
|
|
|
# Generate data
|
|
x = np.linspace(0, 10, 100)
|
|
y = np.sin(x)
|
|
|
|
# Create plot
|
|
plt.figure(figsize=(10, 6))
|
|
plt.plot(x, y)
|
|
plt.title('Sine Wave')
|
|
plt.xlabel('x')
|
|
plt.ylabel('sin(x)')
|
|
plt.grid(True)
|
|
|
|
# Save plot
|
|
plt.savefig('sine_wave.png')
|
|
print("Plot saved successfully")
|
|
"""
|
|
|
|
# Mock only get_tool_by_name (database lookup)
|
|
with patch(
|
|
"onyx.tools.tool_implementations_v2.python.get_tool_by_name"
|
|
) as mock_get_tool:
|
|
mock_tool = Mock()
|
|
mock_tool.id = 1
|
|
mock_get_tool.return_value = mock_tool
|
|
|
|
# Execute code - file store operations happen for real
|
|
result = _python_execution_core(mock_run_context, code, code_interpreter_client)
|
|
|
|
# Verify result
|
|
assert isinstance(result, LlmPythonExecutionResult)
|
|
assert result.exit_code == 0
|
|
assert "Plot saved successfully" in result.stdout
|
|
assert len(result.generated_files) == 1
|
|
|
|
# Verify file metadata
|
|
generated_file = result.generated_files[0]
|
|
assert generated_file.filename == "sine_wave.png"
|
|
assert ".png" in generated_file.filename
|
|
|
|
# Extract file_id from file_link
|
|
file_id = generated_file.file_link.split("/")[-1]
|
|
|
|
# Verify we can read the file back from the file store
|
|
file_store = get_default_file_store()
|
|
file_io = file_store.read_file(file_id)
|
|
file_content = file_io.read()
|
|
|
|
# Verify the file is a valid PNG (check PNG magic bytes)
|
|
# PNG magic bytes: 89 50 4E 47 0D 0A 1A 0A
|
|
assert file_content[:8] == b"\x89PNG\r\n\x1a\n"
|
|
assert len(file_content) > 1000 # PNG should be substantial
|
|
|
|
|
|
def test_python_execution_context_updates(
|
|
mock_run_context: RunContextWrapper[ChatTurnContext],
|
|
code_interpreter_client: CodeInterpreterClient,
|
|
) -> None:
|
|
"""Test that run_context is properly updated."""
|
|
code = 'print("Context update test")'
|
|
|
|
# Mock get_tool_by_name
|
|
with patch(
|
|
"onyx.tools.tool_implementations_v2.python.get_tool_by_name"
|
|
) as mock_get_tool:
|
|
mock_tool = Mock()
|
|
mock_tool.id = 42
|
|
mock_get_tool.return_value = mock_tool
|
|
|
|
# Set specific run step - will be incremented to 6 by @tool_accounting
|
|
mock_run_context.context.current_run_step = 5
|
|
|
|
# Execute code
|
|
_python_execution_core(mock_run_context, code, code_interpreter_client)
|
|
|
|
# Verify iteration_instructions was updated
|
|
# Note: @tool_accounting increments from 5 to 6
|
|
assert len(mock_run_context.context.iteration_instructions) == 1
|
|
instruction = mock_run_context.context.iteration_instructions[0]
|
|
assert instruction.iteration_nr == 6
|
|
assert instruction.plan == "Executing Python code"
|
|
assert instruction.purpose == "Running Python code"
|
|
assert "secure environment" in instruction.reasoning
|
|
|
|
# Verify global_iteration_responses was updated
|
|
assert len(mock_run_context.context.global_iteration_responses) == 1
|
|
answer = mock_run_context.context.global_iteration_responses[0]
|
|
assert answer.tool == "PythonTool"
|
|
assert answer.tool_id == 42
|
|
assert answer.iteration_nr == 6
|
|
assert answer.parallelization_nr == 0
|
|
assert answer.question == "Execute Python code"
|
|
assert answer.reasoning and "secure environment" in answer.reasoning
|
|
assert "Context update test" in answer.answer
|
|
assert answer.cited_documents == {}
|
|
|
|
# Verify packets were emitted with correct index
|
|
mock_emitter = mock_run_context.context.run_dependencies.emitter
|
|
emitter_calls = mock_emitter.emit.call_args_list # type: ignore
|
|
for call in emitter_calls:
|
|
packet = call[0][0]
|
|
assert isinstance(packet, Packet)
|
|
assert packet.ind == 6
|
|
|
|
|
|
def test_python_tool_availability_with_url_set(db_session: Session) -> None:
|
|
"""Test PythonTool.is_available() returns True when URL is configured."""
|
|
with patch(
|
|
"onyx.tools.tool_implementations.python.python_tool.CODE_INTERPRETER_BASE_URL",
|
|
"http://localhost:8000",
|
|
):
|
|
assert PythonTool.is_available(db_session) is True
|
|
|
|
|
|
def test_python_tool_availability_without_url(db_session: Session) -> None:
|
|
"""Test PythonTool.is_available() returns False when URL is not configured."""
|
|
with patch(
|
|
"onyx.tools.tool_implementations.python.python_tool.CODE_INTERPRETER_BASE_URL",
|
|
None,
|
|
):
|
|
assert PythonTool.is_available(db_session) is False
|
|
|
|
with patch(
|
|
"onyx.tools.tool_implementations.python.python_tool.CODE_INTERPRETER_BASE_URL",
|
|
"",
|
|
):
|
|
assert PythonTool.is_available(db_session) is False
|
|
|
|
|
|
def test_python_function_tool_wrapper(
|
|
mock_run_context: RunContextWrapper[ChatTurnContext],
|
|
code_interpreter_client: CodeInterpreterClient,
|
|
) -> None:
|
|
"""Test the @function_tool decorated python() wrapper function."""
|
|
code = 'print("Testing function tool wrapper")'
|
|
|
|
# Mock get_tool_by_name and patch CodeInterpreterClient to use our fixture
|
|
with patch(
|
|
"onyx.tools.tool_implementations_v2.python.get_tool_by_name"
|
|
) as mock_get_tool:
|
|
with patch(
|
|
"onyx.tools.tool_implementations_v2.python.CodeInterpreterClient"
|
|
) as mock_client_class:
|
|
mock_tool = Mock()
|
|
mock_tool.id = 1
|
|
mock_get_tool.return_value = mock_tool
|
|
mock_client_class.return_value = code_interpreter_client
|
|
|
|
# Call the function tool wrapper
|
|
result_coro = python.on_invoke_tool(mock_run_context, json.dumps({"code": code})) # type: ignore
|
|
result_json: str = asyncio.run(result_coro) # type: ignore
|
|
|
|
# Verify result is JSON string
|
|
assert isinstance(result_json, str)
|
|
|
|
# Parse and verify result
|
|
adapter = TypeAdapter(LlmPythonExecutionResult)
|
|
result = adapter.validate_json(result_json)
|
|
|
|
assert isinstance(result, LlmPythonExecutionResult)
|
|
assert "Testing function tool wrapper" in result.stdout
|
|
assert result.exit_code == 0
|
|
|
|
|
|
def test_python_execution_output_truncation(
|
|
mock_run_context: RunContextWrapper[ChatTurnContext],
|
|
code_interpreter_client: CodeInterpreterClient,
|
|
) -> None:
|
|
"""Test that large outputs are properly truncated."""
|
|
# Generate code that produces output larger than truncation limit
|
|
code = """
|
|
for i in range(10000):
|
|
print(f"Line {i}: " + "x" * 100)
|
|
"""
|
|
|
|
# Mock get_tool_by_name
|
|
with patch(
|
|
"onyx.tools.tool_implementations_v2.python.get_tool_by_name"
|
|
) as mock_get_tool:
|
|
# Set a small truncation limit for testing
|
|
with patch(
|
|
"onyx.tools.tool_implementations_v2.python.CODE_INTERPRETER_MAX_OUTPUT_LENGTH",
|
|
5000,
|
|
):
|
|
mock_tool = Mock()
|
|
mock_tool.id = 1
|
|
mock_get_tool.return_value = mock_tool
|
|
|
|
# Execute code
|
|
result = _python_execution_core(
|
|
mock_run_context, code, code_interpreter_client
|
|
)
|
|
|
|
# Verify output was truncated
|
|
assert len(result.stdout) <= 5000 + 200 # Allow for truncation message
|
|
assert "output truncated" in result.stdout
|
|
assert "characters omitted" in result.stdout
|
|
|
|
|
|
def test_python_execution_multiple_files(
|
|
mock_run_context: RunContextWrapper[ChatTurnContext],
|
|
code_interpreter_client: CodeInterpreterClient,
|
|
db_session: Session, # Needed to initialize DB engine for file_store
|
|
) -> None:
|
|
"""Test generation of multiple files."""
|
|
code = """
|
|
# Create multiple files
|
|
with open('file1.txt', 'w') as f:
|
|
f.write('Content of file 1')
|
|
|
|
with open('file2.txt', 'w') as f:
|
|
f.write('Content of file 2')
|
|
|
|
with open('file3.txt', 'w') as f:
|
|
f.write('Content of file 3')
|
|
|
|
print("Created 3 files")
|
|
"""
|
|
|
|
# Mock only get_tool_by_name (database lookup)
|
|
with patch(
|
|
"onyx.tools.tool_implementations_v2.python.get_tool_by_name"
|
|
) as mock_get_tool:
|
|
mock_tool = Mock()
|
|
mock_tool.id = 1
|
|
mock_get_tool.return_value = mock_tool
|
|
|
|
# Execute code - file store operations happen for real
|
|
result = _python_execution_core(mock_run_context, code, code_interpreter_client)
|
|
|
|
# Verify result
|
|
assert isinstance(result, LlmPythonExecutionResult)
|
|
assert result.exit_code == 0
|
|
assert "Created 3 files" in result.stdout
|
|
assert len(result.generated_files) == 3
|
|
|
|
# Verify all files have unique IDs and proper metadata
|
|
file_ids_result = [f.file_link.split("/")[-1] for f in result.generated_files]
|
|
assert len(set(file_ids_result)) == 3 # All unique
|
|
|
|
# Verify filenames
|
|
filenames = [f.filename for f in result.generated_files]
|
|
assert "file1.txt" in filenames
|
|
assert "file2.txt" in filenames
|
|
assert "file3.txt" in filenames
|
|
|
|
# Verify we can read all files back from the file store
|
|
file_store = get_default_file_store()
|
|
|
|
# Create a mapping of filename to generated file for easier verification
|
|
files_by_name = {f.filename: f for f in result.generated_files}
|
|
|
|
# Verify each expected file
|
|
for i in range(1, 4):
|
|
filename = f"file{i}.txt"
|
|
assert filename in files_by_name, f"Expected file {filename} not found"
|
|
|
|
generated_file = files_by_name[filename]
|
|
file_id = generated_file.file_link.split("/")[-1]
|
|
file_io = file_store.read_file(file_id)
|
|
file_content = file_io.read()
|
|
expected_content = f"Content of file {i}".encode()
|
|
assert (
|
|
expected_content in file_content
|
|
), f"Expected content not found in {filename}"
|
|
|
|
|
|
def test_python_execution_client_error_handling(
|
|
mock_run_context: RunContextWrapper[ChatTurnContext],
|
|
) -> None:
|
|
"""Test error handling when Code Interpreter service fails."""
|
|
code = 'print("Test")'
|
|
|
|
# Create a client that will fail
|
|
if not CODE_INTERPRETER_BASE_URL:
|
|
pytest.skip("CODE_INTERPRETER_BASE_URL not configured")
|
|
|
|
client = CodeInterpreterClient()
|
|
|
|
# Mock the execute method to raise an exception
|
|
with patch.object(client, "execute", side_effect=Exception("Service unavailable")):
|
|
# Execute code
|
|
result = _python_execution_core(mock_run_context, code, client)
|
|
|
|
# Verify error result
|
|
assert isinstance(result, LlmPythonExecutionResult)
|
|
assert result.exit_code == -1
|
|
error_msg = result.error or ""
|
|
assert "Service unavailable" in result.stderr or "Service unavailable" in error_msg
|
|
assert not result.timed_out
|
|
assert len(result.generated_files) == 0
|
|
|
|
# Verify error delta was emitted
|
|
mock_emitter = mock_run_context.context.run_dependencies.emitter
|
|
emitter_calls = mock_emitter.emit.call_args_list # type: ignore
|
|
delta_packets = [
|
|
call[0][0]
|
|
for call in emitter_calls
|
|
if isinstance(call[0][0].obj, PythonToolDelta)
|
|
]
|
|
assert len(delta_packets) >= 1
|
|
assert "Service unavailable" in delta_packets[-1].obj.stderr
|
|
|
|
|
|
def test_python_execution_with_excel_file(
|
|
mock_run_context: RunContextWrapper[ChatTurnContext],
|
|
code_interpreter_client: CodeInterpreterClient,
|
|
db_session: Session, # Needed to initialize DB engine for file_store
|
|
) -> None:
|
|
"""Test Excel file generation with financial data."""
|
|
code = """
|
|
import pandas as pd
|
|
|
|
# Create financial sample data
|
|
data = {
|
|
'Segment': ['Government', 'Government', 'Midmarket', 'Midmarket', 'Enterprise'],
|
|
'Country': ['Canada', 'Germany', 'France', 'Germany', 'Canada'],
|
|
'Product': ['Carretera', 'Carretera', 'Carretera', 'Carretera', 'Amarilla'],
|
|
'Units Sold': [1618.5, 1321, 2178, 888, 2470],
|
|
'Manufacturing Price': [3, 3, 3, 3, 260],
|
|
'Sale Price': [20, 20, 20, 20, 300],
|
|
'Gross Sales': [32370, 26420, 43560, 17760, 741000],
|
|
'Discounts': [0, 0, 0, 0, 0],
|
|
'Sales': [32370, 26420, 43560, 17760, 741000],
|
|
'COGS': [16850, 13940, 22800, 9390, 642000],
|
|
'Profit': [15520, 12480, 20760, 8370, 99000],
|
|
'Month': ['January', 'January', 'June', 'April', 'September']
|
|
}
|
|
|
|
# Create DataFrame
|
|
df = pd.DataFrame(data)
|
|
|
|
# Write to Excel
|
|
df.to_excel('financial_report.xlsx', index=False, sheet_name='Financial Data')
|
|
|
|
print(f"Excel file created with {len(df)} rows")
|
|
"""
|
|
|
|
# Mock only get_tool_by_name (database lookup)
|
|
with patch(
|
|
"onyx.tools.tool_implementations_v2.python.get_tool_by_name"
|
|
) as mock_get_tool:
|
|
mock_tool = Mock()
|
|
mock_tool.id = 1
|
|
mock_get_tool.return_value = mock_tool
|
|
|
|
# Execute code - file store operations happen for real
|
|
result = _python_execution_core(mock_run_context, code, code_interpreter_client)
|
|
|
|
# Verify result
|
|
assert isinstance(result, LlmPythonExecutionResult)
|
|
assert result.exit_code == 0
|
|
assert "Excel file created with 5 rows" in result.stdout
|
|
assert len(result.generated_files) == 1
|
|
|
|
# Verify file metadata
|
|
generated_file = result.generated_files[0]
|
|
assert generated_file.filename == "financial_report.xlsx"
|
|
assert ".xlsx" in generated_file.filename
|
|
|
|
# Extract file_id from file_link
|
|
file_id = generated_file.file_link.split("/")[-1]
|
|
|
|
# Verify we can read the file back from the file store
|
|
file_store = get_default_file_store()
|
|
file_io = file_store.read_file(file_id)
|
|
file_content = file_io.read()
|
|
|
|
# Verify the file is a valid Excel file (check ZIP magic bytes - xlsx is a ZIP archive)
|
|
# ZIP magic bytes: 50 4B 03 04
|
|
assert file_content[:4] == b"PK\x03\x04"
|
|
assert len(file_content) > 1000 # Excel file should be substantial
|
|
|
|
# Verify we can parse the Excel file with openpyxl directly
|
|
file_io = io.BytesIO(file_content)
|
|
workbook = load_workbook(file_io)
|
|
sheet = workbook["Financial Data"]
|
|
|
|
# Verify data structure - get headers from first row
|
|
first_row = list(sheet.iter_rows(min_row=1, max_row=1, values_only=True))[0]
|
|
headers = list(first_row) if first_row else []
|
|
expected_columns = [
|
|
"Segment",
|
|
"Country",
|
|
"Product",
|
|
"Units Sold",
|
|
"Manufacturing Price",
|
|
"Sale Price",
|
|
"Gross Sales",
|
|
"Discounts",
|
|
"Sales",
|
|
"COGS",
|
|
"Profit",
|
|
"Month",
|
|
]
|
|
assert headers == expected_columns
|
|
|
|
# Verify row count (excluding header)
|
|
assert sheet.max_row == 6 # 1 header + 5 data rows
|
|
|
|
# Read data rows
|
|
rows = []
|
|
for row in sheet.iter_rows(min_row=2, values_only=True):
|
|
rows.append(row)
|
|
|
|
assert len(rows) == 5
|
|
|
|
# Verify some sample data
|
|
segments = [row[0] for row in rows]
|
|
countries = [row[1] for row in rows]
|
|
units_sold = [float(row[3]) if row[3] is not None else 0.0 for row in rows] # type: ignore
|
|
profits = [float(row[10]) if row[10] is not None else 0.0 for row in rows] # type: ignore
|
|
|
|
assert "Government" in segments
|
|
assert "Canada" in countries
|
|
assert sum(units_sold) > 8000 # Total units sold
|
|
assert sum(profits) > 155000 # Total profit
|
|
|
|
|
|
def test_python_execution_with_excel_file_input(
|
|
mock_run_context: RunContextWrapper[ChatTurnContext],
|
|
code_interpreter_client: CodeInterpreterClient,
|
|
db_session: Session, # Needed to initialize DB engine for file_store
|
|
) -> None:
|
|
"""Test processing an uploaded Excel file - reading and analyzing it."""
|
|
# Load the sample Excel file
|
|
import os
|
|
|
|
test_file_path = os.path.join(
|
|
os.path.dirname(__file__), "data", "financial-sample.xlsx"
|
|
)
|
|
|
|
with open(test_file_path, "rb") as f:
|
|
file_content = f.read()
|
|
|
|
# Create InMemoryChatFile with the Excel file
|
|
chat_file = InMemoryChatFile(
|
|
file_id="test-financial-sample",
|
|
content=file_content,
|
|
file_type=ChatFileType.DOC,
|
|
filename="financial-sample.xlsx",
|
|
)
|
|
|
|
# Add the file to the mock context's chat_files
|
|
mock_run_context.context.chat_files = [chat_file]
|
|
|
|
# Code to analyze the uploaded Excel file
|
|
code = """
|
|
import pandas as pd
|
|
import matplotlib
|
|
matplotlib.use('Agg')
|
|
import matplotlib.pyplot as plt
|
|
from openpyxl import load_workbook
|
|
|
|
# Read the uploaded Excel file using openpyxl directly
|
|
workbook = load_workbook('financial-sample.xlsx')
|
|
sheet = workbook.active
|
|
|
|
# Convert to pandas DataFrame
|
|
data = []
|
|
headers = [cell.value for cell in sheet[1]]
|
|
for row in sheet.iter_rows(min_row=2, values_only=True):
|
|
data.append(row)
|
|
|
|
df = pd.DataFrame(data, columns=headers)
|
|
|
|
print(f"Loaded Excel file with {len(df)} rows and {len(df.columns)} columns")
|
|
print(f"\\nColumns: {', '.join(df.columns.tolist())}")
|
|
|
|
# Perform analysis
|
|
print(f"\\n=== Analysis ===")
|
|
|
|
# Group by segment and calculate total sales and profit
|
|
segment_summary = df.groupby('Segment').agg({
|
|
' Sales': 'sum',
|
|
'Profit': 'sum',
|
|
'Units Sold': 'sum'
|
|
}).round(2)
|
|
|
|
print(f"\\nSales by Segment:")
|
|
print(segment_summary)
|
|
|
|
# Find top 5 products by profit
|
|
top_products = df.groupby('Product')['Profit'].sum().sort_values(ascending=False).head(5)
|
|
print(f"\\nTop 5 Products by Profit:")
|
|
print(top_products)
|
|
|
|
# Calculate profit margin
|
|
total_sales = df[' Sales'].sum()
|
|
total_profit = df['Profit'].sum()
|
|
profit_margin = (total_profit / total_sales * 100) if total_sales > 0 else 0
|
|
print(f"\\nOverall Profit Margin: {profit_margin:.2f}%")
|
|
|
|
# Create a visualization
|
|
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
|
|
|
|
# Sales by Segment
|
|
segment_summary[' Sales'].plot(kind='bar', ax=ax1, color='steelblue')
|
|
ax1.set_title('Total Sales by Segment')
|
|
ax1.set_xlabel('Segment')
|
|
ax1.set_ylabel('Sales ($)')
|
|
ax1.tick_params(axis='x', rotation=45)
|
|
|
|
# Top 5 Products by Profit
|
|
top_products.plot(kind='barh', ax=ax2, color='seagreen')
|
|
ax2.set_title('Top 5 Products by Profit')
|
|
ax2.set_xlabel('Profit ($)')
|
|
ax2.set_ylabel('Product')
|
|
|
|
plt.tight_layout()
|
|
plt.savefig('financial_analysis.png', dpi=100, bbox_inches='tight')
|
|
print(f"\\nVisualization saved as financial_analysis.png")
|
|
|
|
# Create summary report Excel file
|
|
summary_data = {
|
|
'Metric': ['Total Sales', 'Total Profit', 'Profit Margin %', 'Total Units Sold', 'Number of Records'],
|
|
'Value': [
|
|
f"${total_sales:,.2f}",
|
|
f"${total_profit:,.2f}",
|
|
f"{profit_margin:.2f}%",
|
|
f"{df['Units Sold'].sum():,.0f}",
|
|
len(df)
|
|
]
|
|
}
|
|
summary_df = pd.DataFrame(summary_data)
|
|
|
|
with pd.ExcelWriter('financial_summary.xlsx') as writer:
|
|
summary_df.to_excel(writer, sheet_name='Summary', index=False)
|
|
segment_summary.to_excel(writer, sheet_name='By Segment')
|
|
|
|
print(f"Summary report saved as financial_summary.xlsx")
|
|
"""
|
|
|
|
# Mock only get_tool_by_name (database lookup)
|
|
with patch(
|
|
"onyx.tools.tool_implementations_v2.python.get_tool_by_name"
|
|
) as mock_get_tool:
|
|
mock_tool = Mock()
|
|
mock_tool.id = 1
|
|
mock_get_tool.return_value = mock_tool
|
|
|
|
# Execute code - file store operations happen for real
|
|
result = _python_execution_core(mock_run_context, code, code_interpreter_client)
|
|
|
|
# Verify result
|
|
assert isinstance(result, LlmPythonExecutionResult)
|
|
assert result.exit_code == 0
|
|
assert "Loaded Excel file" in result.stdout
|
|
assert "Analysis" in result.stdout
|
|
assert "Sales by Segment" in result.stdout
|
|
assert "Top 5 Products by Profit" in result.stdout
|
|
assert "Profit Margin" in result.stdout
|
|
|
|
# Should generate 2 files: PNG visualization and Excel summary
|
|
assert len(result.generated_files) == 2
|
|
|
|
# Verify generated files
|
|
filenames = [f.filename for f in result.generated_files]
|
|
assert "financial_analysis.png" in filenames
|
|
assert "financial_summary.xlsx" in filenames
|
|
|
|
# Verify we can read and validate the generated files
|
|
file_store = get_default_file_store()
|
|
|
|
# Check the PNG file
|
|
png_file = next(
|
|
f for f in result.generated_files if f.filename == "financial_analysis.png"
|
|
)
|
|
png_file_id = png_file.file_link.split("/")[-1]
|
|
png_io = file_store.read_file(png_file_id)
|
|
png_content = png_io.read()
|
|
assert png_content[:8] == b"\x89PNG\r\n\x1a\n" # PNG magic bytes
|
|
assert len(png_content) > 5000 # Should be substantial
|
|
|
|
# Check the Excel summary file
|
|
xlsx_file = next(
|
|
f for f in result.generated_files if f.filename == "financial_summary.xlsx"
|
|
)
|
|
xlsx_file_id = xlsx_file.file_link.split("/")[-1]
|
|
xlsx_io = file_store.read_file(xlsx_file_id)
|
|
xlsx_content = xlsx_io.read()
|
|
assert xlsx_content[:4] == b"PK\x03\x04" # ZIP/Excel magic bytes
|
|
|
|
# Parse and verify the summary Excel file using openpyxl directly
|
|
xlsx_io_obj = io.BytesIO(xlsx_content)
|
|
workbook = load_workbook(xlsx_io_obj)
|
|
sheet = workbook["Summary"]
|
|
|
|
# Read headers from first row
|
|
first_row = list(sheet.iter_rows(min_row=1, max_row=1, values_only=True))[0]
|
|
headers = list(first_row) if first_row else []
|
|
assert "Metric" in headers
|
|
assert "Value" in headers
|
|
|
|
# Read all rows and extract metrics
|
|
metrics = []
|
|
for row in sheet.iter_rows(min_row=2, values_only=True):
|
|
if row[0]: # Metric column
|
|
metrics.append(row[0])
|
|
|
|
assert "Total Sales" in metrics
|
|
assert "Total Profit" in metrics
|
|
assert "Profit Margin %" in metrics
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Run with: python -m pytest tests/external_dependency_unit/tools/test_python_tool.py -v
|
|
pytest.main([__file__, "-v"])
|