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

60 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-17 00:00 +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=A005,B008,B009,B010,DUO102,PIE799,PT011,TRY003,TRY101,W503"), 

44 ("pyroma", "."), 

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

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

47 ("dodgy", "."), 

48 ("pyrefly", "check", "."), 

49) 

50 

51#: the rule sets we use for ruff 

52__RUFF_RULES: Final[str] =\ 

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

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

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

56 "UP,W,YTT") 

57 

58#: the ruff rules that we ignore 

59__RUFF_IGNORE: Final[str] =\ 

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

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

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

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

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

65 

66#: the pylint rules that we ignore 

67__PYLINT_IGNORE: Final[str] =\ 

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

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

70 

71#: the ruff target version 

72__RUF_TARGET_VERSION: Final[str] = "py312" 

73 

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

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

76 ("pyflakes", "."), 

77 ("pylint", ".", __PYLINT_IGNORE), 

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

79 "--explicit-package-bases"), 

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

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

82 ("unimport", "."), 

83 ("pycodestyle", "."), 

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

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

86) 

87 

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

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

90 ("pylint", ".", __PYLINT_IGNORE), 

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

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

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

94 ("unimport", "."), 

95 ("pycodestyle", "."), 

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

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

98) 

99 

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

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

102 ("pylint", ".", __PYLINT_IGNORE), 

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

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

105 ("unimport", "."), 

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

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

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

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

110) 

111 

112#: a list of analysis to be applied to the documentation source directory 

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

114 

115 

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

117 """ 

118 Perform the static code analysis for a Python project. 

119 

120 :param info: the build information 

121 

122 >>> from contextlib import redirect_stdout 

123 >>> with redirect_stdout(None): 

124 ... static_analysis(BuildInfo( 

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

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

127 

128 >>> try: 

129 ... static_analysis(None) 

130 ... except TypeError as te: 

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

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

133 

134 >>> try: 

135 ... static_analysis(1) 

136 ... except TypeError as te: 

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

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

139 """ 

140 if not isinstance(info, BuildInfo): 

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

142 

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

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

145 

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

147 try: 

148 errors: list[str] = [] 

149 for what, analysis, path in ( 

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

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

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

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

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

155 if path is None: 

156 continue 

157 

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

159 # we will only check this single file. 

160 use_path: Path = path 

161 single_file: Path | None = None 

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

163 if thepath.is_file(): 

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

165 if single_file is None: 

166 single_file = thepath 

167 else: 

168 single_file = None 

169 break 

170 else: 

171 single_file = None 

172 break 

173 if single_file is not None: 

174 use_path = single_file 

175 

176 for a in analysis: 

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

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

179 finally: 

180 chdir(current) 

181 

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

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

184 return 

185 

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

187 for error in errors: 

188 logger(error + "\n") 

189 

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

191 

192 

193# Run static analysis program if executed as script. 

194# This part cannot appear in unit test coverage, but we do test it. 

195if __name__ == "__main__": # pragma: no cover 

196 parser: Final[ArgumentParser] = pycommons_argparser( 

197 __file__, 

198 "Apply Static Code Analysis Tools", 

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

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

201 static_analysis(parse_project_arguments(parser))