OpenHands/tests/unit/test_circular_imports.py

398 lines
15 KiB
Python

import os
import re
import unittest
class TestCircularImports(unittest.TestCase):
"""Test to detect circular imports in the codebase."""
def test_no_circular_imports_in_key_modules(self):
"""
Test that there are no circular imports in key modules that were previously problematic.
This test specifically checks the modules that were involved in a previous circular import issue:
- openhands.utils.prompt
- openhands.agenthub.codeact_agent.tools.bash
- openhands.agenthub.codeact_agent.tools.prompt
- openhands.memory.memory
- openhands.memory.conversation_memory
"""
# Get the project root directory
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))
# Map module names to file paths
module_paths = {
'openhands.utils.prompt': os.path.join(
project_root, 'openhands/utils/prompt.py'
),
'openhands.agenthub.codeact_agent.tools.bash': os.path.join(
project_root, 'openhands/agenthub/codeact_agent/tools/bash.py'
),
'openhands.agenthub.codeact_agent.tools.prompt': os.path.join(
project_root, 'openhands/agenthub/codeact_agent/tools/prompt.py'
),
'openhands.memory.memory': os.path.join(
project_root, 'openhands/memory/memory.py'
),
'openhands.memory.conversation_memory': os.path.join(
project_root, 'openhands/memory/conversation_memory.py'
),
}
# Check for the specific circular import pattern that was problematic
circular_imports = self._find_circular_imports(module_paths)
# If there are any circular imports, fail the test
if circular_imports:
circular_import_str = '\n'.join(
[
f'{module1} -> {module2} -> {module1}'
for module1, module2 in circular_imports
]
)
self.fail(f'Circular imports detected:\n{circular_import_str}')
def _find_circular_imports(
self, module_paths: dict[str, str]
) -> list[tuple[str, str]]:
"""
Find circular imports between modules.
Args:
module_paths: Dictionary mapping module names to file paths
Returns:
List of tuples (module1, module2) where module1 imports module2 and module2 imports module1
"""
# Dictionary to store imports for each module
module_imports = {}
# Extract imports for each module
for module_name, file_path in module_paths.items():
if os.path.exists(file_path):
with open(file_path, 'r') as f:
source_code = f.read()
# Extract import statements
import_lines = [
line.strip()
for line in source_code.split('\n')
if line.strip().startswith(('import ', 'from '))
and not line.strip().startswith('# ')
]
# Parse import statements to get imported modules
imported_modules = []
for line in import_lines:
if line.startswith('import '):
# Handle "import module" or "import module as alias"
parts = line[7:].split(',')
for part in parts:
module_part = part.strip().split(' as ')[0].strip()
if module_part.startswith('openhands.'):
imported_modules.append(module_part)
elif line.startswith('from '):
# Handle "from module import name" or "from module import name as alias"
module_part = line[5:].split(' import ')[0].strip()
if module_part.startswith('openhands.'):
imported_modules.append(module_part)
module_imports[module_name] = imported_modules
# Check for circular imports
circular_imports = []
for module1, imports1 in module_imports.items():
for module2 in imports1:
if module2 in module_imports and module1 in module_imports[module2]:
# Found a circular import
circular_imports.append((module1, module2))
return circular_imports
def test_specific_circular_import_pattern(self):
"""
Test for the specific circular import pattern that caused the issue in the stack trace.
The problematic pattern was:
openhands.utils.prompt imports from openhands.agenthub.codeact_agent.tools.bash
openhands.agenthub.codeact_agent.tools.bash imports from openhands.agenthub.codeact_agent.tools.prompt
openhands.agenthub.codeact_agent.tools.prompt imports from openhands.utils.prompt
"""
# Get the project root directory
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))
# Check if the problematic pattern exists
prompt_path = os.path.join(project_root, 'openhands/utils/prompt.py')
bash_path = os.path.join(
project_root, 'openhands/agenthub/codeact_agent/tools/bash.py'
)
tools_prompt_path = os.path.join(
project_root, 'openhands/agenthub/codeact_agent/tools/prompt.py'
)
# Check if all files exist
if not all(
os.path.exists(path) for path in [prompt_path, bash_path, tools_prompt_path]
):
self.skipTest('One or more required files do not exist')
# Read the files
with open(prompt_path, 'r') as f:
prompt_code = f.read()
with open(bash_path, 'r') as f:
bash_code = f.read()
with open(tools_prompt_path, 'r') as f:
tools_prompt_code = f.read()
# Check for the problematic imports
prompt_imports_bash = (
re.search(
r'from openhands\.agenthub\.codeact_agent\.tools\.bash import',
prompt_code,
)
is not None
)
bash_imports_tools_prompt = (
re.search(
r'from openhands\.agenthub\.codeact_agent\.tools\.prompt import',
bash_code,
)
is not None
)
tools_prompt_imports_prompt = (
re.search(r'from openhands\.utils\.prompt import', tools_prompt_code)
is not None
)
# If all three imports exist, we have a circular import
if (
prompt_imports_bash
and bash_imports_tools_prompt
and tools_prompt_imports_prompt
):
self.fail(
'Circular import pattern detected:\n'
'openhands.utils.prompt imports from openhands.agenthub.codeact_agent.tools.bash\n'
'openhands.agenthub.codeact_agent.tools.bash imports from openhands.agenthub.codeact_agent.tools.prompt\n'
'openhands.agenthub.codeact_agent.tools.prompt imports from openhands.utils.prompt'
)
def test_detect_circular_imports_in_server_modules(self):
"""
Test for circular imports in the server modules that were involved in the stack trace.
The problematic modules were:
- openhands.server.shared
- openhands.server.conversation_manager.conversation_manager
- openhands.server.session.agent_session
- openhands.server.session
- openhands.server.session.session
"""
# Get the project root directory
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))
# Map module names to file paths
module_paths = {
'openhands.server.shared': os.path.join(
project_root, 'openhands/server/shared.py'
),
'openhands.server.conversation_manager.conversation_manager': os.path.join(
project_root,
'openhands/server/conversation_manager/conversation_manager.py',
),
'openhands.server.session.agent_session': os.path.join(
project_root, 'openhands/server/session/agent_session.py'
),
'openhands.server.session.__init__': os.path.join(
project_root, 'openhands/server/session/__init__.py'
),
'openhands.server.session.session': os.path.join(
project_root, 'openhands/server/session/session.py'
),
}
# Check for circular imports
circular_imports = self._find_circular_imports(module_paths)
# If there are any circular imports, fail the test
if circular_imports:
circular_import_str = '\n'.join(
[
f'{module1} -> {module2} -> {module1}'
for module1, module2 in circular_imports
]
)
self.fail(
f'Circular imports detected in server modules:\n{circular_import_str}'
)
def test_detect_circular_imports_in_mcp_modules(self):
"""
Test for circular imports in the MCP modules that were involved in the stack trace.
The problematic modules were:
- openhands.mcp
- openhands.mcp.utils
- openhands.memory.memory
"""
# Get the project root directory
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))
# Map module names to file paths
module_paths = {
'openhands.mcp.__init__': os.path.join(
project_root, 'openhands/mcp/__init__.py'
),
'openhands.mcp.utils': os.path.join(project_root, 'openhands/mcp/utils.py'),
'openhands.memory.memory': os.path.join(
project_root, 'openhands/memory/memory.py'
),
}
# Check for circular imports
circular_imports = self._find_circular_imports(module_paths)
# If there are any circular imports, fail the test
if circular_imports:
circular_import_str = '\n'.join(
[
f'{module1} -> {module2} -> {module1}'
for module1, module2 in circular_imports
]
)
self.fail(
f'Circular imports detected in MCP modules:\n{circular_import_str}'
)
def test_detect_complex_circular_import_chains(self):
"""
Test for complex circular import chains involving multiple modules.
This test checks for circular dependencies that involve more than two modules,
such as A imports B, B imports C, and C imports A.
"""
# Get the project root directory
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))
# Define the modules involved in the stack trace
modules = [
'openhands.utils.prompt',
'openhands.agenthub.codeact_agent.tools.bash',
'openhands.agenthub.codeact_agent.tools.prompt',
'openhands.memory.memory',
'openhands.memory.conversation_memory',
'openhands.server.shared',
'openhands.server.conversation_manager.conversation_manager',
'openhands.server.session.agent_session',
'openhands.server.session.__init__',
'openhands.server.session.session',
'openhands.mcp.__init__',
'openhands.mcp.utils',
]
# Map module names to file paths
module_paths = {}
for module in modules:
if module.endswith('.__init__'):
# Handle __init__.py files
module_path = module[:-9].replace('.', '/')
file_path = os.path.join(project_root, f'{module_path}/__init__.py')
else:
# Handle regular .py files
module_path = module.replace('.', '/')
file_path = os.path.join(project_root, f'{module_path}.py')
if os.path.exists(file_path):
module_paths[module] = file_path
# Build the import graph
import_graph = {}
for module_name, file_path in module_paths.items():
with open(file_path, 'r') as f:
source_code = f.read()
# Extract import statements
import_lines = [
line.strip()
for line in source_code.split('\n')
if line.strip().startswith(('import ', 'from '))
and not line.strip().startswith('# ')
]
# Parse import statements to get imported modules
imported_modules = []
for line in import_lines:
if line.startswith('import '):
# Handle "import module" or "import module as alias"
parts = line[7:].split(',')
for part in parts:
module_part = part.strip().split(' as ')[0].strip()
if module_part.startswith('openhands.'):
imported_modules.append(module_part)
elif line.startswith('from '):
# Handle "from module import name" or "from module import name as alias"
module_part = line[5:].split(' import ')[0].strip()
if module_part.startswith('openhands.'):
imported_modules.append(module_part)
import_graph[module_name] = [
m for m in imported_modules if m in module_paths
]
# Check for circular import chains
circular_chains = self._find_circular_chains(import_graph)
# If there are any circular chains, fail the test
if circular_chains:
circular_chain_str = '\n'.join(
[' -> '.join(chain) for chain in circular_chains]
)
self.fail(f'Complex circular import chains detected:\n{circular_chain_str}')
def _find_circular_chains(
self, import_graph: dict[str, list[str]]
) -> list[list[str]]:
"""
Find circular import chains in the import graph.
Args:
import_graph: Dictionary mapping module names to lists of imported modules
Returns:
List of circular import chains, where each chain is a list of module names
"""
circular_chains = []
def dfs(module: str, path: list[str], visited: set[str]):
"""
Depth-first search to find circular import chains.
Args:
module: Current module being visited
path: Current path in the DFS
visited: Set of modules visited in the current DFS path
"""
if module in visited:
# Found a circular import chain
cycle_start = path.index(module)
circular_chains.append(path[cycle_start:] + [module])
return
visited.add(module)
path.append(module)
for imported_module in import_graph.get(module, []):
dfs(imported_module, path.copy(), visited.copy())
# Start DFS from each module
for module in import_graph:
dfs(module, [], set())
return circular_chains
if __name__ == '__main__':
unittest.main()