Coverage for src / codeaudit / complexitycheck.py: 75%

57 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-09 09:33 +0200

1""" 

2License GPLv3 or higher. 

3 

4(C) 2025 Created by Maikel Mardjan - https://nocomplexity.com/ 

5 

6This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. 

7 

8This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. 

9 

10You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. 

11 

12Simple Cyclomatic Complexity check for Python source files 

13See the docs for in-depth and why this is a simple way , but good enough! 

14""" 

15 

16import ast 

17import warnings 

18from pathlib import Path 

19 

20 

21class ComplexityVisitor(ast.NodeVisitor): 

22 def __init__(self): 

23 self.complexity = 1 # Start with 1 for the entry point 

24 

25 def visit_If(self, node): 

26 self.complexity += 1 

27 self.generic_visit(node) 

28 

29 def visit_For(self, node): 

30 self.complexity += 1 

31 self.generic_visit(node) 

32 

33 def visit_While(self, node): 

34 self.complexity += 1 

35 self.generic_visit(node) 

36 

37 def visit_Try(self, node): 

38 self.complexity += 1 # For the try block 

39 self.generic_visit(node) 

40 

41 def visit_ExceptHandler(self, node): 

42 self.complexity += 1 # For each except clause 

43 self.generic_visit(node) 

44 

45 def visit_With(self, node): 

46 self.complexity += 1 # For 'with' statements 

47 self.generic_visit(node) 

48 

49 def visit_BoolOp(self, node): 

50 # Count 'and' and 'or' operators 

51 # Each 'and' or 'or' introduces a new predicate 

52 self.complexity += len(node.values) - 1 

53 self.generic_visit(node) 

54 

55 def visit_Match(self, node): 

56 # A 'match' statement itself adds to complexity 

57 self.complexity += 1 

58 self.generic_visit(node) 

59 

60 def visit_MatchCase(self, node): 

61 # Each 'case' in a match statement is a distinct path 

62 # Note: The 'visit_Match' already adds 1. Each subsequent 'MatchCase' 

63 # For simplicity and aligning with common CC interpretations where each distinct 

64 # path adds 1, we'll increment for each MatchCase. 

65 # So count each case as a separate branch point from the 'match' entry. 

66 self.complexity += 1 

67 self.generic_visit(node) 

68 

69 def visit_Assert(self, node): 

70 # Assert statements introduce a potential exit point (branch) 

71 self.complexity += 1 

72 self.generic_visit(node) 

73 

74 

75def calculate_complexity(code): 

76 with warnings.catch_warnings(): 

77 warnings.simplefilter("ignore", category=SyntaxWarning) 

78 tree = ast.parse(code) 

79 

80 visitor = ComplexityVisitor() 

81 visitor.visit(tree) 

82 return visitor.complexity 

83 

84 

85def count_static_warnings_in_file(file_path, max_file_size=10_000_000): 

86 """ 

87 Parses a Python source file using AST and counts the number of warnings raised (e.g., SyntaxWarning). 

88 

89 Args: 

90 file_path (str or Path): Path to the Python source file. 

91 max_file_size (int, optional): Maximum allowed file size in bytes (default: 10 MB). 

92 

93 Returns: 

94 dict: {"warnings": int} - Number of static warnings detected during parsing. 

95 Returns -1 if the file cannot be read or parsed. 

96 """ 

97 try: 

98 # Convert to Path and resolve 

99 file_path = Path(file_path).expanduser().resolve() 

100 

101 # Security: Check file exists and is a regular file 

102 if not file_path.is_file(): 

103 return -1 

104 

105 # Security: Max file size protection 

106 if file_path.stat().st_size > max_file_size: 

107 return -1 

108 

109 # Read source code safely 

110 source = file_path.read_text(encoding="utf-8") 

111 

112 # Capture warnings during AST parsing 

113 with warnings.catch_warnings(record=True) as caught_warnings: 

114 warnings.simplefilter("always") 

115 ast.parse(source, filename=str(file_path)) 

116 

117 return {"warnings": len(caught_warnings)} 

118 

119 except (SyntaxError, UnicodeDecodeError, ValueError): 

120 return -1