test_code_quality.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. """
  2. Code quality tests for BamBuddy backend.
  3. These tests check for common anti-patterns and code quality issues
  4. that could cause runtime errors but aren't caught by normal tests.
  5. """
  6. import ast
  7. import os
  8. import pytest
  9. from pathlib import Path
  10. # Get the backend source directory
  11. BACKEND_DIR = Path(__file__).parent.parent.parent / "app"
  12. # Safe imports that are commonly re-imported in functions without issues
  13. # These are typically imported at the START of a function, not midway through
  14. SAFE_REIMPORT_NAMES = {
  15. 'logging', 're', 'os', 'sys', 'json', 'Path', 'datetime', 'timedelta',
  16. 'asyncio', 'time', 'typing', 'Optional', 'List', 'Dict', 'Any', 'Union',
  17. }
  18. class DangerousImportVisitor(ast.NodeVisitor):
  19. """AST visitor that detects dangerous import patterns.
  20. Specifically looks for cases where:
  21. 1. A name is imported at module level
  22. 2. The same name is imported locally in a function
  23. 3. The name is USED before the local import in that function
  24. This pattern causes 'cannot access local variable' errors.
  25. """
  26. def __init__(self):
  27. self.module_imports: set[str] = set()
  28. self.dangerous_imports: list[tuple[str, int, str, int]] = [] # (name, import_line, function, first_use_line)
  29. self.current_function: str | None = None
  30. self.function_start_line: int = 0
  31. self.in_function = False
  32. def visit_Import(self, node: ast.Import):
  33. for alias in node.names:
  34. name = alias.asname or alias.name
  35. if not self.in_function:
  36. self.module_imports.add(name)
  37. self.generic_visit(node)
  38. def visit_ImportFrom(self, node: ast.ImportFrom):
  39. for alias in node.names:
  40. name = alias.asname or alias.name
  41. if not self.in_function:
  42. self.module_imports.add(name)
  43. self.generic_visit(node)
  44. def _check_function(self, node):
  45. """Check a function for dangerous import patterns."""
  46. if not self.in_function:
  47. return
  48. # Skip safe reimports
  49. # Collect all local imports in this function
  50. local_imports: dict[str, int] = {} # name -> line number
  51. name_uses: dict[str, int] = {} # name -> first use line number
  52. for child in ast.walk(node):
  53. # Find local imports
  54. if isinstance(child, (ast.Import, ast.ImportFrom)):
  55. if isinstance(child, ast.Import):
  56. for alias in child.names:
  57. name = alias.asname or alias.name
  58. if name in self.module_imports and name not in SAFE_REIMPORT_NAMES:
  59. local_imports[name] = child.lineno
  60. elif isinstance(child, ast.ImportFrom):
  61. for alias in child.names:
  62. name = alias.asname or alias.name
  63. if name in self.module_imports and name not in SAFE_REIMPORT_NAMES:
  64. local_imports[name] = child.lineno
  65. # Find name uses
  66. if isinstance(child, ast.Name):
  67. if child.id not in name_uses:
  68. name_uses[child.id] = child.lineno
  69. # Check for dangerous pattern: use before import
  70. for name, import_line in local_imports.items():
  71. if name in name_uses:
  72. first_use = name_uses[name]
  73. if first_use < import_line:
  74. self.dangerous_imports.append((name, import_line, self.current_function, first_use))
  75. def visit_FunctionDef(self, node: ast.FunctionDef):
  76. old_function = self.current_function
  77. old_in_function = self.in_function
  78. old_start_line = self.function_start_line
  79. self.current_function = node.name
  80. self.in_function = True
  81. self.function_start_line = node.lineno
  82. self._check_function(node)
  83. self.generic_visit(node)
  84. self.current_function = old_function
  85. self.in_function = old_in_function
  86. self.function_start_line = old_start_line
  87. def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
  88. old_function = self.current_function
  89. old_in_function = self.in_function
  90. old_start_line = self.function_start_line
  91. self.current_function = node.name
  92. self.in_function = True
  93. self.function_start_line = node.lineno
  94. self._check_function(node)
  95. self.generic_visit(node)
  96. self.current_function = old_function
  97. self.in_function = old_in_function
  98. self.function_start_line = old_start_line
  99. def find_import_shadowing(file_path: Path) -> list[tuple[str, int, str]]:
  100. """Find cases where local imports shadow module-level imports AND are used before import.
  101. Returns list of (name, line_number, function_name) tuples.
  102. """
  103. try:
  104. with open(file_path, 'r') as f:
  105. source = f.read()
  106. tree = ast.parse(source)
  107. visitor = DangerousImportVisitor()
  108. visitor.visit(tree)
  109. # Convert (name, import_line, function, first_use_line) to (name, import_line, function)
  110. return [(name, import_line, func) for name, import_line, func, _ in visitor.dangerous_imports]
  111. except SyntaxError:
  112. return [] # Skip files with syntax errors
  113. def get_python_files(directory: Path) -> list[Path]:
  114. """Get all Python files in a directory recursively."""
  115. return list(directory.rglob("*.py"))
  116. class TestImportShadowing:
  117. """Tests for import shadowing anti-pattern."""
  118. def test_no_import_shadowing_in_main(self):
  119. """Check main.py has no import shadowing issues.
  120. This test would have caught the ArchiveService scoping bug.
  121. """
  122. main_file = BACKEND_DIR / "main.py"
  123. if not main_file.exists():
  124. pytest.skip("main.py not found")
  125. shadows = find_import_shadowing(main_file)
  126. if shadows:
  127. error_msg = "Import shadowing detected in main.py:\n"
  128. for name, line, func in shadows:
  129. error_msg += f" - '{name}' at line {line} in function '{func}' shadows module-level import\n"
  130. error_msg += "\nThis can cause 'cannot access local variable' errors."
  131. pytest.fail(error_msg)
  132. def test_no_import_shadowing_in_services(self):
  133. """Check service files have no import shadowing issues."""
  134. services_dir = BACKEND_DIR / "services"
  135. if not services_dir.exists():
  136. pytest.skip("services directory not found")
  137. all_shadows = []
  138. for py_file in get_python_files(services_dir):
  139. shadows = find_import_shadowing(py_file)
  140. for name, line, func in shadows:
  141. all_shadows.append((py_file.name, name, line, func))
  142. if all_shadows:
  143. error_msg = "Import shadowing detected in services:\n"
  144. for filename, name, line, func in all_shadows:
  145. error_msg += f" - {filename}: '{name}' at line {line} in function '{func}'\n"
  146. pytest.fail(error_msg)
  147. def test_no_import_shadowing_in_routes(self):
  148. """Check route files have no import shadowing issues."""
  149. routes_dir = BACKEND_DIR / "api" / "routes"
  150. if not routes_dir.exists():
  151. pytest.skip("routes directory not found")
  152. all_shadows = []
  153. for py_file in get_python_files(routes_dir):
  154. shadows = find_import_shadowing(py_file)
  155. for name, line, func in shadows:
  156. all_shadows.append((py_file.name, name, line, func))
  157. if all_shadows:
  158. error_msg = "Import shadowing detected in routes:\n"
  159. for filename, name, line, func in all_shadows:
  160. error_msg += f" - {filename}: '{name}' at line {line} in function '{func}'\n"
  161. pytest.fail(error_msg)
  162. class TestModuleImports:
  163. """Tests for module import health."""
  164. def test_all_modules_importable(self):
  165. """Verify all Python modules can be imported without errors.
  166. This catches syntax errors and missing dependencies.
  167. """
  168. import importlib
  169. import sys
  170. # Modules to test importing
  171. modules = [
  172. "backend.app.main",
  173. "backend.app.services.bambu_mqtt",
  174. "backend.app.services.printer_manager",
  175. "backend.app.services.archive",
  176. "backend.app.services.notification_service",
  177. "backend.app.services.smart_plug_manager",
  178. ]
  179. errors = []
  180. for module_name in modules:
  181. try:
  182. # Remove from cache first to ensure fresh import
  183. if module_name in sys.modules:
  184. del sys.modules[module_name]
  185. importlib.import_module(module_name)
  186. except Exception as e:
  187. errors.append(f"{module_name}: {type(e).__name__}: {e}")
  188. if errors:
  189. pytest.fail("Failed to import modules:\n" + "\n".join(errors))
  190. class TestLogErrorPatterns:
  191. """Tests that use log capture to detect runtime errors."""
  192. def test_mqtt_message_processing_no_errors(self, capture_logs):
  193. """Test that MQTT message processing doesn't log errors."""
  194. from backend.app.services.bambu_mqtt import BambuMQTTClient
  195. client = BambuMQTTClient(
  196. ip_address="192.168.1.100",
  197. serial_number="TEST123",
  198. access_code="12345678",
  199. )
  200. client.on_print_start = lambda data: None
  201. client.on_print_complete = lambda data: None
  202. # Process a realistic print lifecycle
  203. messages = [
  204. {"print": {"gcode_state": "RUNNING", "gcode_file": "/test.gcode", "subtask_name": "Test"}},
  205. {"print": {"gcode_state": "RUNNING", "gcode_file": "/test.gcode", "mc_percent": 50}},
  206. {"print": {"gcode_state": "FINISH", "gcode_file": "/test.gcode", "subtask_name": "Test"}},
  207. ]
  208. for msg in messages:
  209. client._process_message(msg)
  210. assert not capture_logs.has_errors(), f"Errors during MQTT processing:\n{capture_logs.format_errors()}"