Coverage for src/su6/cli.py: 85%

112 statements  

« prev     ^ index     » next       coverage.py v7.2.6, created at 2023-05-30 11:54 +0200

1"""This file contains all Typer Commands.""" 

2import typing 

3from json import load as json_load 

4 

5import typer 

6from plumbum import local 

7from plumbum.commands.processes import CommandNotFound, ProcessExecutionError 

8from rich import print 

9 

10from .core import ( 

11 DEFAULT_VERBOSITY, 

12 EXIT_CODE_COMMAND_NOT_FOUND, 

13 EXIT_CODE_ERROR, 

14 EXIT_CODE_SUCCESS, 

15 GREEN_CIRCLE, 

16 RED_CIRCLE, 

17 YELLOW_CIRCLE, 

18 Verbosity, 

19 info, 

20 log_cmd_output, 

21 log_command, 

22 state, 

23 warn, 

24 with_exit_code, 

25) 

26 

27app = typer.Typer() 

28 

29 

30def _check_tool(tool: str, *args: str) -> int: 

31 """ 

32 Abstraction to run one of the cli checking tools and process its output. 

33 

34 Args: 

35 tool: the (bash) name of the tool to run 

36 . \ 

37 Level 1 (quiet) will only print a colored circle indicating success/failure; \ 

38 Level 2 (normal) will also print stdout/stderr; \ 

39 Level 3 (verbose) will also print the executed command with its arguments. 

40 """ 

41 try: 

42 cmd = local[tool] 

43 

44 if state.verbosity >= 3: 

45 log_command(cmd, args) 

46 result = cmd(*args) 

47 print(GREEN_CIRCLE, tool) 

48 if state.verbosity > 2: # pragma: no cover 

49 log_cmd_output(result) 

50 return EXIT_CODE_SUCCESS # success 

51 except CommandNotFound: 

52 if state.verbosity > 2: # pragma: no cover 

53 warn(f"Tool {tool} not installed!") 

54 print(YELLOW_CIRCLE, tool) 

55 return EXIT_CODE_COMMAND_NOT_FOUND # command not found 

56 except ProcessExecutionError as e: 

57 print(RED_CIRCLE, tool) 

58 if state.verbosity > 1: 

59 log_cmd_output(e.stdout, e.stderr) 

60 return EXIT_CODE_ERROR # general error 

61 

62 

63# 'directory' is an optional cli argument to many commands, so we define the type here for reuse: 

64T_directory: typing.TypeAlias = typing.Annotated[str, typer.Argument()] # = "." 

65 

66 

67@app.command() 

68@with_exit_code() 

69def ruff(directory: T_directory = None) -> int: 

70 """ 

71 Runs the Ruff Linter. 

72 

73 Args: 

74 directory: where to run ruff on (default is current dir) 

75 

76 """ 

77 config = state.update_config(directory=directory) 

78 return _check_tool("ruff", config.directory) 

79 

80 

81@app.command() 

82@with_exit_code() 

83def black(directory: T_directory = None, fix: bool = False) -> int: 

84 """ 

85 Runs the Black code formatter. 

86 

87 Args: 

88 directory: where to run black on (default is current dir) 

89 fix: if --fix is passed, black will be used to reformat the file(s). 

90 

91 """ 

92 config = state.update_config(directory=directory) 

93 

94 args = [config.directory, "--exclude=venv.+|.+\.bak"] 

95 if not fix: 

96 args.append("--check") 

97 elif state.verbosity > 2: 

98 info("note: running WITHOUT --check -> changing files") 

99 

100 return _check_tool("black", *args) 

101 

102 

103@app.command() 

104@with_exit_code() 

105def isort(directory: T_directory = None, fix: bool = False) -> int: 

106 """ 

107 Runs the import sort (isort) utility. 

108 

109 Args: 

110 directory: where to run isort on (default is current dir) 

111 fix: if --fix is passed, isort will be used to rearrange imports. 

112 

113 """ 

114 config = state.update_config(directory=directory) 

115 args = [config.directory] 

116 if not fix: 

117 args.append("--check-only") 

118 elif state.verbosity > 2: 

119 info("note: running WITHOUT --check -> changing files") 

120 

121 return _check_tool("isort", *args) 

122 

123 

124@app.command() 

125@with_exit_code() 

126def mypy(directory: T_directory = None) -> int: 

127 """ 

128 Runs the mypy static type checker. 

129 

130 Args: 

131 directory: where to run mypy on (default is current dir) 

132 

133 """ 

134 config = state.update_config(directory=directory) 

135 return _check_tool("mypy", config.directory) 

136 

137 

138@app.command() 

139@with_exit_code() 

140def bandit(directory: T_directory = None) -> int: 

141 """ 

142 Runs the bandit security checker. 

143 

144 Args: 

145 directory: where to run bandit on (default is current dir) 

146 

147 """ 

148 config = state.update_config(directory=directory) 

149 return _check_tool("bandit", "-r", "-c", config.pyproject, config.directory) 

150 

151 

152@app.command() 

153@with_exit_code() 

154def pydocstyle(directory: T_directory = None) -> int: 

155 """ 

156 Runs the pydocstyle docstring checker. 

157 

158 Args: 

159 directory: where to run pydocstyle on (default is current dir) 

160 

161 """ 

162 config = state.update_config(directory=directory) 

163 return _check_tool("pydocstyle", config.directory) 

164 

165 

166@app.command() 

167@with_exit_code() 

168def pytest( 

169 directory: T_directory = None, html: bool = False, json: bool = False, coverage: int = None 

170) -> int: 

171 """ 

172 Runs all pytests. 

173 

174 Args: 

175 directory: where to run pytests on (default is current dir) 

176 html: generate HTML coverage output? 

177 json: generate JSON coverage output? 

178 coverage: threshold for coverage (in %) 

179 

180 Example: 

181 > su6 pytest --coverage 50 

182 if any checks fail: exit 1 and red circle 

183 if all checks pass but coverage is less than 50%: exit 1, green circle for pytest and red for coverage 

184 if all check pass and coverage is at least 50%: exit 0, green circle for pytest and green for coverage 

185 

186 if --coverage is not passed, there will be no circle for coverage. 

187 """ 

188 config = state.update_config(directory=directory, coverage=coverage) 

189 

190 args = ["--cov", config.directory] 

191 

192 if config.coverage: 

193 # json output required! 

194 json = True 

195 

196 if html: 

197 args.extend(["--cov-report", "html"]) 

198 

199 if json: 

200 args.extend(["--cov-report", "json"]) 

201 

202 exit_code = _check_tool("pytest", *args) 

203 

204 if config.coverage: 

205 with open("coverage.json") as f: 

206 data = json_load(f) 

207 percent_covered = round(data["totals"]["percent_covered"]) 

208 

209 # if actual coverage is less than the the threshold, exit code should be success (0) 

210 exit_code = percent_covered < config.coverage 

211 circle = RED_CIRCLE if exit_code else GREEN_CIRCLE 

212 print(circle, "coverage") 

213 

214 return exit_code 

215 

216 

217@app.command(name="all") 

218@with_exit_code() 

219def check_all(directory: T_directory = None, ignore_uninstalled: bool = False, coverage: float = None) -> bool: 

220 """ 

221 Run all available checks. 

222 

223 Args: 

224 directory: where to run the tools on (default is current dir) 

225 ignore_uninstalled: use --ignore-uninstalled to skip exit code 127 (command not found) 

226 coverage: pass to pytest() 

227 

228 """ 

229 config = state.update_config(directory=directory) 

230 ignored_exit_codes = set() 

231 if ignore_uninstalled: 

232 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND) 

233 

234 tools = config.determine_which_to_run([ruff, black, mypy, bandit, isort, pydocstyle, pytest]) 

235 

236 exit_codes = [] 

237 for tool in tools: 

238 a = [directory] 

239 kw = dict(_suppress=True, _ignore=ignored_exit_codes) 

240 

241 if tool is pytest: # pragma: no cover 

242 kw["coverage"] = coverage 

243 

244 exit_codes.append(tool(*a, **kw)) 

245 

246 return any(exit_codes) 

247 

248 

249@app.command(name="fix") 

250@with_exit_code() 

251def do_fix(directory: T_directory = None, ignore_uninstalled: bool = False) -> bool: 

252 """ 

253 Do everything that's safe to fix (so not ruff because that may break semantics). 

254 

255 Args: 

256 directory: where to run the tools on (default is current dir) 

257 ignore_uninstalled: use --ignore-uninstalled to skip exit code 127 (command not found) 

258 

259 """ 

260 config = state.update_config(directory=directory) 

261 

262 ignored_exit_codes = set() 

263 if ignore_uninstalled: 

264 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND) 

265 

266 tools = config.determine_which_to_run([black, isort]) 

267 

268 exit_codes = [tool(directory, fix=True, _suppress=True, _ignore=ignored_exit_codes) for tool in tools] 

269 

270 return any(exit_codes) 

271 

272 

273@app.callback() 

274def main(config: str = None, verbosity: Verbosity = DEFAULT_VERBOSITY) -> None: 

275 """ 

276 This callback will run before every command, setting the right global flags. 

277 

278 Args: 

279 config: path to a different config toml file 

280 verbosity: level of detail to print out (1 - 3) 

281 

282 Todo: 

283 - add --format option for json 

284 """ 

285 state.load_config(config_file=config, verbosity=verbosity) 

286 

287 

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

289 app()