Files
onyx/backend/tests/external_dependency_unit/tools/test_python_tool.py
T

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"])