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

100 statements  

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

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

2import contextlib 

3import math 

4import os 

5import typing 

6from json import load as json_load 

7 

8import typer 

9from plumbum import local 

10from plumbum.commands.processes import CommandNotFound, ProcessExecutionError 

11from rich import print 

12 

13from .core import ( 

14 DEFAULT_FORMAT, 

15 DEFAULT_VERBOSITY, 

16 EXIT_CODE_COMMAND_NOT_FOUND, 

17 EXIT_CODE_ERROR, 

18 EXIT_CODE_SUCCESS, 

19 GREEN_CIRCLE, 

20 RED_CIRCLE, 

21 YELLOW_CIRCLE, 

22 Format, 

23 Verbosity, 

24 dump_tools_with_results, 

25 info, 

26 log_cmd_output, 

27 log_command, 

28 state, 

29 warn, 

30 with_exit_code, DEFAULT_BADGE, 

31) 

32 

33app = typer.Typer() 

34 

35 

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

37 """ 

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

39 

40 Args: 

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

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

43 """ 

44 try: 

45 cmd = local[tool] 

46 

47 if state.verbosity >= 3: 

48 log_command(cmd, args) 

49 

50 result = cmd(*args) 

51 

52 if state.format == "text": 

53 print(GREEN_CIRCLE, tool) 

54 

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

56 log_cmd_output(result) 

57 

58 return EXIT_CODE_SUCCESS # success 

59 except CommandNotFound: # pragma: no cover 

60 if state.verbosity > 2: 

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

62 

63 if state.format == "text": 

64 print(YELLOW_CIRCLE, tool) 

65 

66 return EXIT_CODE_COMMAND_NOT_FOUND # command not found 

67 except ProcessExecutionError as e: 

68 if state.format == "text": 

69 print(RED_CIRCLE, tool) 

70 

71 if state.verbosity > 1: 

72 log_cmd_output(e.stdout, e.stderr) 

73 return EXIT_CODE_ERROR # general error 

74 

75 

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

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

78 

79 

80@app.command() 

81@with_exit_code() 

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

83 """ 

84 Runs the Ruff Linter. 

85 

86 Args: 

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

88 

89 """ 

90 config = state.update_config(directory=directory) 

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

92 

93 

94@app.command() 

95@with_exit_code() 

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

97 """ 

98 Runs the Black code formatter. 

99 

100 Args: 

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

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

103 

104 """ 

105 config = state.update_config(directory=directory) 

106 

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

108 if not fix: 

109 args.append("--check") 

110 elif state.verbosity > 2: 

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

112 

113 return _check_tool("black", *args) 

114 

115 

116@app.command() 

117@with_exit_code() 

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

119 """ 

120 Runs the import sort (isort) utility. 

121 

122 Args: 

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

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

125 

126 """ 

127 config = state.update_config(directory=directory) 

128 args = [config.directory] 

129 if not fix: 

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

131 elif state.verbosity > 2: 

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

133 

134 return _check_tool("isort", *args) 

135 

136 

137@app.command() 

138@with_exit_code() 

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

140 """ 

141 Runs the mypy static type checker. 

142 

143 Args: 

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

145 

146 """ 

147 config = state.update_config(directory=directory) 

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

149 

150 

151@app.command() 

152@with_exit_code() 

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

154 """ 

155 Runs the bandit security checker. 

156 

157 Args: 

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

159 

160 """ 

161 config = state.update_config(directory=directory) 

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

163 

164 

165@app.command() 

166@with_exit_code() 

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

168 """ 

169 Runs the pydocstyle docstring checker. 

170 

171 Args: 

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

173 

174 """ 

175 config = state.update_config(directory=directory) 

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

177 

178 

179@app.command() 

180@with_exit_code() 

181def pytest( 

182 directory: T_directory = None, 

183 html: bool = False, 

184 json: bool = False, 

185 coverage: int = None, 

186 badge: bool = None, 

187) -> int: # pragma: no cover 

188 """ 

189 Runs all pytests. 

190 

191 Args: 

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

193 html: generate HTML coverage output? 

194 json: generate JSON coverage output? 

195 coverage: threshold for coverage (in %) 

196 badge: generate coverage badge (svg)? If you want to change the name, do this in pyproject.toml 

197 

198 Example: 

199 > su6 pytest --coverage 50 

200 if any checks fail: exit 1 and red circle 

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

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

203 

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

205 """ 

206 config = state.update_config(directory=directory, coverage=coverage, badge=badge) 

207 

208 if config.badge and config.coverage is None: 

209 # not None but still check cov 

210 config.coverage = 0 

211 

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

213 

214 if config.coverage is not None: 

215 # json output required! 

216 json = True 

217 

218 if html: 

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

220 

221 if json: 

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

223 

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

225 

226 if config.coverage is not None: 

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

228 data = json_load(f) 

229 percent_covered = math.floor(data["totals"]["percent_covered"]) 

230 

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

232 exit_code = percent_covered < config.coverage 

233 circle = RED_CIRCLE if exit_code else GREEN_CIRCLE 

234 if state.format == "text": 

235 print(circle, "coverage") 

236 

237 if config.badge: 

238 if not isinstance(config.badge, str): 

239 # it's still True for some reason? 

240 config.badge = DEFAULT_BADGE 

241 

242 with contextlib.suppress(FileNotFoundError): 

243 os.remove(config.badge) 

244 

245 result = local["coverage-badge"]("-o", config.badge) 

246 if state.verbosity > 2: 

247 print(result) 

248 

249 return exit_code 

250 

251 

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

253@with_exit_code() 

254def check_all( 

255 directory: T_directory = None, ignore_uninstalled: bool = False, coverage: float = None, badge: bool = None 

256) -> bool: 

257 """ 

258 Run all available checks. 

259 

260 Args: 

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

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

263 coverage: pass to pytest() 

264 badge: pass to pytest() 

265 

266 """ 

267 config = state.update_config(directory=directory) 

268 ignored_exit_codes = set() 

269 if ignore_uninstalled: 

270 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND) 

271 

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

273 

274 exit_codes = [] 

275 for tool in tools: 

276 a = [directory] 

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

278 

279 if tool is pytest: # pragma: no cover 

280 kw["coverage"] = coverage 

281 kw["badge"] = badge 

282 

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

284 

285 if state.format == "json": 

286 dump_tools_with_results(tools, exit_codes) 

287 

288 return any(exit_codes) 

289 

290 

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

292@with_exit_code() 

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

294 """ 

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

296 

297 Args: 

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

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

300 

301 """ 

302 config = state.update_config(directory=directory) 

303 

304 ignored_exit_codes = set() 

305 if ignore_uninstalled: 

306 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND) 

307 

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

309 

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

311 

312 if state.format == "json": 

313 dump_tools_with_results(tools, exit_codes) 

314 

315 return any(exit_codes) 

316 

317 

318@app.callback() 

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

320 """ 

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

322 

323 Args: 

324 config: path to a different config toml file 

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

326 format: output format 

327 

328 """ 

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

330 

331 

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

333 app()