Coverage for pycommons / dev / building / static_analysis.py: 94%

63 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-11 03:04 +0000

1"""Perform the static code analysis.""" 

2 

3from argparse import ArgumentParser 

4from os import chdir, getcwd 

5from typing import Any, Callable, Final, Iterable 

6 

7from pycommons.dev.building.build_info import ( 

8 BuildInfo, 

9 parse_project_arguments, 

10 replace_in_cmd, 

11) 

12from pycommons.io.arguments import pycommons_argparser 

13from pycommons.io.console import logger 

14from pycommons.io.path import Path, directory_path 

15from pycommons.processes.shell import Command 

16from pycommons.types import type_error 

17 

18 

19def __exec(arguments: Iterable[str], 

20 info: BuildInfo, 

21 errors: Callable[[str], Any]) -> None: 

22 """ 

23 Execute a command. 

24 

25 :param arguments: the arguments 

26 :param info: the build info 

27 :param errors: the error collector 

28 """ 

29 cmd: Final[Command] = info.command(arguments) 

30 try: 

31 cmd.execute(False) 

32 except ValueError as ve: 

33 errors(f"{cmd} failed with {ve!r}.") 

34 

35 

36#: the files to exclude 

37__EXCLUDES: Final[str] =\ 

38 ".svn,CVS,.bzr,.hg,.git,__pycache__,.tox,.nox,.eggs,*.egg,.venv" 

39 

40#: a list of analysis to be applied to the base directory 

41__BASE_ANALYSES: Final[tuple[tuple[str, ...], ...]] = ( 

42 ("flake8", ".", "--exclude", __EXCLUDES, 

43 "--ignore=B008,B009,B010,DUO102,TRY003,TRY101,W503"), 

44 ("pyroma", "."), 

45 ("pydocstyle", ".", "--convention=pep257"), 

46 ("vulture", ".", "--exclude", __EXCLUDES, "--min-confidence", "61"), 

47 ("dodgy", "."), 

48) 

49 

50#: the rule sets we use for ruff 

51__RUFF_RULES: Final[str] =\ 

52 ("--select=A,AIR,ANN,ARG,ASYNC,B,BLE,C,C4,COM,D,DJ,DOC,DTZ,E,ERA,EXE,F," 

53 "FA,FIX,FLY,FURB,G,I,ICN,INP,ISC,INT,LOG,N,NPY,PERF,PGH,PIE,PL,PLC,PLE," 

54 "PLR,PLW,PT,PYI,Q,RET,RSE,RUF,S,SIM,SLF,SLOT,T,T10,T20,TC,TD,TID,TRY," 

55 "UP,W,YTT") 

56 

57#: the ruff rules that we ignore 

58__RUFF_IGNORE: Final[str] =\ 

59 ("--ignore=A005,ANN001,ANN002,ANN003,ANN204,ANN401,ARG002,B008,B009,B010," 

60 "C901,D203,D208,D212,D401,D407,D413,DOC201,DOC402,DOC501,FURB189,N801," 

61 "PGH003,PGH004,PLC2801,PLR0904,PLR0911,PLR0912,PLR0913,PLR0914,PLR0915," 

62 "PLR0916,PLR0917,PLR1702,PLR2004,PLR6301,PT011,PT012,PT013," 

63 "PYI041,RUF100,S,TC001,TC002,SLF001,TRY003,UP035,UP047,W") 

64 

65#: the pylint rules that we ignore 

66__PYLINT_IGNORE: Final[str] =\ 

67 ("--disable=C0103,C0302,C0325,R0801,R0901,R0902,R0903,R0911,R0912,R0913," 

68 "R0914,R0915,R0916,R0917,R1702,R1728,W0212,W0238,W0703") 

69 

70#: the ruff target version 

71__RUF_TARGET_VERSION: Final[str] = "py312" 

72 

73#: a list of analysis to be applied to the package directory 

74__PACKAGE_ANALYSES: Final[tuple[tuple[str, ...], ...]] = ( 

75 ("pyflakes", "."), 

76 ("pylint", ".", __PYLINT_IGNORE), 

77 ("mypy", ".", "--no-strict-optional", "--check-untyped-defs", 

78 "--explicit-package-bases"), 

79 ("bandit", "-r", ".", "-s", "B311"), 

80 ("tryceratops", ".", "-i", "TRY003", "-i", "TRY101"), 

81 ("unimport", "."), 

82 ("pycodestyle", "."), 

83 ("ruff", "check", "--target-version", __RUF_TARGET_VERSION, __RUFF_RULES, 

84 __RUFF_IGNORE, "--line-length", "79", "--preview", "."), 

85) 

86 

87#: a list of analysis to be applied to the test directory 

88__TESTS_ANALYSES: Final[tuple[tuple[str, ...], ...]] = ( 

89 ("pylint", ".", __PYLINT_IGNORE), 

90 ("mypy", ".", "--no-strict-optional", "--check-untyped-defs"), 

91 ("bandit", "-r", ".", "-s", "B311,B101"), 

92 ("tryceratops", ".", "-i", "TRY003", "-i", "TRY101"), 

93 ("unimport", "."), 

94 ("pycodestyle", "."), 

95 ("ruff", "check", "--target-version", __RUF_TARGET_VERSION, 

96 __RUFF_RULES, f"{__RUFF_IGNORE},INP001", "--preview", "."), 

97) 

98 

99#: a list of analysis to be applied to the examples directory 

100__EXAMPLES_ANALYSES: Final[tuple[tuple[str, ...], ...]] = ( 

101 ("pylint", ".", __PYLINT_IGNORE), 

102 ("bandit", "-r", ".", "-s", "B311"), 

103 ("tryceratops", ".", "-i", "TRY003", "-i", "TRY101"), 

104 ("unimport", "."), 

105 ("pycodestyle", "--ignore=E731,W503", "."), 

106 ("ruff", "check", "--target-version", __RUF_TARGET_VERSION, 

107 __RUFF_RULES.replace(",T20", ""), f"{__RUFF_IGNORE},INP001,T201", 

108 "--line-length", "79", "--preview", "."), 

109) 

110 

111#: a list of analysis to be applied to the examples directory 

112__DOC_SOURCE: Final[tuple[tuple[str, ...], ...]] = __EXAMPLES_ANALYSES 

113 

114 

115def static_analysis(info: BuildInfo) -> None: 

116 """ 

117 Perform the static code analysis for a Python project. 

118 

119 :param info: the build information 

120 

121 >>> from contextlib import redirect_stdout 

122 >>> with redirect_stdout(None): 

123 ... static_analysis(BuildInfo( 

124 ... Path(__file__).up(4), "pycommons", "tests", 

125 ... "examples", "docs/source")) 

126 

127 >>> try: 

128 ... static_analysis(None) 

129 ... except TypeError as te: 

130 ... print(str(te)[:50]) 

131 info should be an instance of pycommons.dev.buildi 

132 

133 >>> try: 

134 ... static_analysis(1) 

135 ... except TypeError as te: 

136 ... print(str(te)[:50]) 

137 info should be an instance of pycommons.dev.buildi 

138 """ 

139 if not isinstance(info, BuildInfo): 

140 raise type_error(info, "info", BuildInfo) 

141 

142 text: Final[str] = f"static analysis for {info}" 

143 logger(f"Performing {text}.") 

144 

145 current: Final[Path] = directory_path(getcwd()) 

146 try: 

147 errors: list[str] = [] 

148 for what, analysis, path in ( 

149 ("base", __BASE_ANALYSES, info.base_dir), 

150 ("package", __PACKAGE_ANALYSES, info.sources_dir), 

151 ("tests", __TESTS_ANALYSES, info.tests_dir), 

152 ("examples", __EXAMPLES_ANALYSES, info.examples_dir), 

153 ("doc", __DOC_SOURCE, info.doc_source_dir)): 

154 if path is None: 

155 continue 

156 

157 # If we only have a single Python file in the directory, then 

158 # we will only check this single file. 

159 use_path: Path = path 

160 single_file: Path | None = None 

161 for thepath in path.list_dir(True, True): 

162 if thepath.is_file(): 

163 if thepath.endswith(".py"): 

164 if single_file is None: 

165 single_file = thepath 

166 else: 

167 single_file = None 

168 break 

169 else: 

170 single_file = None 

171 break 

172 if single_file is not None: 

173 use_path = single_file 

174 

175 for a in analysis: 

176 logger(f"Applying {a[0]} to {what}.") 

177 __exec(replace_in_cmd(a, use_path), info, errors.append) 

178 finally: 

179 chdir(current) 

180 

181 if list.__len__(errors) <= 0: 

182 logger(f"Successfully completed {text}.") 

183 return 

184 

185 logger(f"The {text} encountered the following errors:") 

186 for error in errors: 

187 logger(error + "\n") 

188 

189 raise ValueError(f"Failed to do {text}:\n{'\n'.join(errors)}") 

190 

191 

192# Run static analysis program if executed as script 

193if __name__ == "__main__": 

194 parser: Final[ArgumentParser] = pycommons_argparser( 

195 __file__, 

196 "Apply Static Code Analysis Tools", 

197 "This utility applies a big heap of static code analysis tools in " 

198 "a unified way as I use it throughout my projects.") 

199 static_analysis(parse_project_arguments(parser))