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

97 statements  

« prev     ^ index     » next       coverage.py v7.2.6, created at 2023-05-30 13:45 +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_FORMAT, 

12 DEFAULT_VERBOSITY, 

13 EXIT_CODE_COMMAND_NOT_FOUND, 

14 EXIT_CODE_ERROR, 

15 EXIT_CODE_SUCCESS, 

16 GREEN_CIRCLE, 

17 RED_CIRCLE, 

18 YELLOW_CIRCLE, 

19 Format, 

20 Verbosity, 

21 dump_tools_with_results, 

22 info, 

23 log_cmd_output, 

24 log_command, 

25 state, 

26 warn, 

27 with_exit_code, 

28) 

29 

30app = typer.Typer() 

31 

32 

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

34 """ 

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

36 

37 Args: 

38 tool: the (bash) name of the tool to run. 

39 args: cli args to pass to the cli bash tool 

40 """ 

41 try: 

42 cmd = local[tool] 

43 

44 if state.verbosity >= 3: 

45 log_command(cmd, args) 

46 

47 result = cmd(*args) 

48 

49 if state.format == "text": 

50 print(GREEN_CIRCLE, tool) 

51 

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

53 log_cmd_output(result) 

54 

55 return EXIT_CODE_SUCCESS # success 

56 except CommandNotFound: # pragma: no cover 

57 if state.verbosity > 2: 

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

59 

60 if state.format == "text": 

61 print(YELLOW_CIRCLE, tool) 

62 

63 return EXIT_CODE_COMMAND_NOT_FOUND # command not found 

64 except ProcessExecutionError as e: 

65 if state.format == "text": 

66 print(RED_CIRCLE, tool) 

67 

68 if state.verbosity > 1: 

69 log_cmd_output(e.stdout, e.stderr) 

70 return EXIT_CODE_ERROR # general error 

71 

72 

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

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

75 

76 

77@app.command() 

78@with_exit_code() 

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

80 """ 

81 Runs the Ruff Linter. 

82 

83 Args: 

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

85 

86 """ 

87 config = state.update_config(directory=directory) 

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

89 

90 

91@app.command() 

92@with_exit_code() 

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

94 """ 

95 Runs the Black code formatter. 

96 

97 Args: 

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

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

100 

101 """ 

102 config = state.update_config(directory=directory) 

103 

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

105 if not fix: 

106 args.append("--check") 

107 elif state.verbosity > 2: 

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

109 

110 return _check_tool("black", *args) 

111 

112 

113@app.command() 

114@with_exit_code() 

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

116 """ 

117 Runs the import sort (isort) utility. 

118 

119 Args: 

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

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

122 

123 """ 

124 config = state.update_config(directory=directory) 

125 args = [config.directory] 

126 if not fix: 

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

128 elif state.verbosity > 2: 

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

130 

131 return _check_tool("isort", *args) 

132 

133 

134@app.command() 

135@with_exit_code() 

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

137 """ 

138 Runs the mypy static type checker. 

139 

140 Args: 

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

142 

143 """ 

144 config = state.update_config(directory=directory) 

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

146 

147 

148@app.command() 

149@with_exit_code() 

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

151 """ 

152 Runs the bandit security checker. 

153 

154 Args: 

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

156 

157 """ 

158 config = state.update_config(directory=directory) 

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

160 

161 

162@app.command() 

163@with_exit_code() 

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

165 """ 

166 Runs the pydocstyle docstring checker. 

167 

168 Args: 

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

170 

171 """ 

172 config = state.update_config(directory=directory) 

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

174 

175 

176@app.command() 

177@with_exit_code() 

178def pytest( 

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

180) -> int: # pragma: no cover 

181 """ 

182 Runs all pytests. 

183 

184 Args: 

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

186 html: generate HTML coverage output? 

187 json: generate JSON coverage output? 

188 coverage: threshold for coverage (in %) 

189 

190 Example: 

191 > su6 pytest --coverage 50 

192 if any checks fail: exit 1 and red circle 

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

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

195 

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

197 """ 

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

199 

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

201 

202 if config.coverage: 

203 # json output required! 

204 json = True 

205 

206 if html: 

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

208 

209 if json: 

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

211 

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

213 

214 if config.coverage: 

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

216 data = json_load(f) 

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

218 

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

220 exit_code = percent_covered < config.coverage 

221 circle = RED_CIRCLE if exit_code else GREEN_CIRCLE 

222 if state.format == "text": 

223 print(circle, "coverage") 

224 

225 return exit_code 

226 

227 

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

229@with_exit_code() 

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

231 """ 

232 Run all available checks. 

233 

234 Args: 

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

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

237 coverage: pass to pytest() 

238 

239 """ 

240 config = state.update_config(directory=directory) 

241 ignored_exit_codes = set() 

242 if ignore_uninstalled: 

243 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND) 

244 

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

246 

247 exit_codes = [] 

248 for tool in tools: 

249 a = [directory] 

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

251 

252 if tool is pytest: # pragma: no cover 

253 kw["coverage"] = coverage 

254 

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

256 

257 if state.format == "json": 

258 dump_tools_with_results(tools, exit_codes) 

259 

260 return any(exit_codes) 

261 

262 

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

264@with_exit_code() 

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

266 """ 

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

268 

269 Args: 

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

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

272 

273 """ 

274 config = state.update_config(directory=directory) 

275 

276 ignored_exit_codes = set() 

277 if ignore_uninstalled: 

278 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND) 

279 

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

281 

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

283 

284 if state.format == "json": 

285 dump_tools_with_results(tools, exit_codes) 

286 

287 return any(exit_codes) 

288 

289 

290@app.callback() 

291def main(config: str = None, verbosity: Verbosity = DEFAULT_VERBOSITY, format: Format = DEFAULT_FORMAT) -> None: 

292 """ 

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

294 

295 Args: 

296 config: path to a different config toml file 

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

298 format: output format 

299 

300 """ 

301 state.load_config(config_file=config, verbosity=verbosity, format=format) 

302 

303 

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

305 app()