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

141 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-05-31 19:29 +0200

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

2import contextlib 

3import math 

4import os 

5import sys 

6import typing 

7from json import load as json_load 

8 

9import typer 

10from plumbum import local 

11from plumbum.commands.processes import CommandNotFound, ProcessExecutionError 

12from rich import print 

13 

14from .__about__ import __version__ 

15from .core import ( 

16 DEFAULT_BADGE, 

17 DEFAULT_FORMAT, 

18 DEFAULT_VERBOSITY, 

19 EXIT_CODE_COMMAND_NOT_FOUND, 

20 EXIT_CODE_ERROR, 

21 EXIT_CODE_SUCCESS, 

22 GREEN_CIRCLE, 

23 RED_CIRCLE, 

24 YELLOW_CIRCLE, 

25 Format, 

26 PlumbumError, 

27 Verbosity, 

28 dump_tools_with_results, 

29 info, 

30 log_cmd_output, 

31 log_command, 

32 print_json, 

33 state, 

34 warn, 

35 with_exit_code, 

36) 

37 

38app = typer.Typer() 

39 

40 

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

42 """ 

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

44 

45 Args: 

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

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

48 """ 

49 try: 

50 cmd = local[tool] 

51 

52 if state.verbosity >= 3: 

53 log_command(cmd, args) 

54 

55 result = cmd(*args) 

56 

57 if state.output_format == "text": 

58 print(GREEN_CIRCLE, tool) 

59 

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

61 log_cmd_output(result) 

62 

63 return EXIT_CODE_SUCCESS # success 

64 except CommandNotFound: # pragma: no cover 

65 if state.verbosity > 2: 

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

67 

68 if state.output_format == "text": 

69 print(YELLOW_CIRCLE, tool) 

70 

71 return EXIT_CODE_COMMAND_NOT_FOUND # command not found 

72 except ProcessExecutionError as e: 

73 if state.output_format == "text": 

74 print(RED_CIRCLE, tool) 

75 

76 if state.verbosity > 1: 

77 log_cmd_output(e.stdout, e.stderr) 

78 return EXIT_CODE_ERROR # general error 

79 

80 

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

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

83 

84 

85@app.command() 

86@with_exit_code() 

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

88 """ 

89 Runs the Ruff Linter. 

90 

91 Args: 

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

93 

94 """ 

95 config = state.update_config(directory=directory) 

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

97 

98 

99@app.command() 

100@with_exit_code() 

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

102 """ 

103 Runs the Black code formatter. 

104 

105 Args: 

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

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

108 

109 """ 

110 config = state.update_config(directory=directory) 

111 

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

113 if not fix: 

114 args.append("--check") 

115 elif state.verbosity > 2: 

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

117 

118 return _check_tool("black", *args) 

119 

120 

121@app.command() 

122@with_exit_code() 

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

124 """ 

125 Runs the import sort (isort) utility. 

126 

127 Args: 

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

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

130 

131 """ 

132 config = state.update_config(directory=directory) 

133 args = [config.directory] 

134 if not fix: 

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

136 elif state.verbosity > 2: 

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

138 

139 return _check_tool("isort", *args) 

140 

141 

142@app.command() 

143@with_exit_code() 

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

145 """ 

146 Runs the mypy static type checker. 

147 

148 Args: 

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

150 

151 """ 

152 config = state.update_config(directory=directory) 

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

154 

155 

156@app.command() 

157@with_exit_code() 

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

159 """ 

160 Runs the bandit security checker. 

161 

162 Args: 

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

164 

165 """ 

166 config = state.update_config(directory=directory) 

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

168 

169 

170@app.command() 

171@with_exit_code() 

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

173 """ 

174 Runs the pydocstyle docstring checker. 

175 

176 Args: 

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

178 

179 """ 

180 config = state.update_config(directory=directory) 

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

182 

183 

184@app.command() 

185@with_exit_code() 

186def pytest( 

187 directory: T_directory = None, 

188 html: bool = False, 

189 json: bool = False, 

190 coverage: int = None, 

191 badge: bool = None, 

192) -> int: # pragma: no cover 

193 """ 

194 Runs all pytests. 

195 

196 Args: 

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

198 html: generate HTML coverage output? 

199 json: generate JSON coverage output? 

200 coverage: threshold for coverage (in %) 

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

202 

203 Example: 

204 > su6 pytest --coverage 50 

205 if any checks fail: exit 1 and red circle 

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

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

208 

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

210 """ 

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

212 

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

214 # not None but still check cov 

215 config.coverage = 0 

216 

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

218 

219 if config.coverage is not None: 

220 # json output required! 

221 json = True 

222 

223 if html: 

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

225 

226 if json: 

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

228 

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

230 

231 if config.coverage is not None: 

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

233 data = json_load(f) 

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

235 

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

237 exit_code = percent_covered < config.coverage 

238 circle = RED_CIRCLE if exit_code else GREEN_CIRCLE 

239 if state.output_format == "text": 

240 print(circle, "coverage") 

241 

242 if config.badge: 

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

244 # it's still True for some reason? 

245 config.badge = DEFAULT_BADGE 

246 

247 with contextlib.suppress(FileNotFoundError): 

248 os.remove(config.badge) 

249 

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

251 if state.verbosity > 2: 

252 info(result) 

253 

254 return exit_code 

255 

256 

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

258@with_exit_code() 

259def check_all( 

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

261) -> bool: 

262 """ 

263 Run all available checks. 

264 

265 Args: 

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

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

268 coverage: pass to pytest() 

269 badge: pass to pytest() 

270 

271 """ 

272 config = state.update_config(directory=directory) 

273 ignored_exit_codes = set() 

274 if ignore_uninstalled: 

275 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND) 

276 

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

278 

279 exit_codes = [] 

280 for tool in tools: 

281 a = [directory] 

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

283 

284 if tool is pytest: # pragma: no cover 

285 kw["coverage"] = coverage 

286 kw["badge"] = badge 

287 

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

289 

290 if state.output_format == "json": 

291 dump_tools_with_results(tools, exit_codes) 

292 

293 return any(exit_codes) 

294 

295 

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

297@with_exit_code() 

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

299 """ 

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

301 

302 Args: 

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

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

305 

306 """ 

307 config = state.update_config(directory=directory) 

308 

309 ignored_exit_codes = set() 

310 if ignore_uninstalled: 

311 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND) 

312 

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

314 

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

316 

317 if state.output_format == "json": 

318 dump_tools_with_results(tools, exit_codes) 

319 

320 return any(exit_codes) 

321 

322 

323@app.command() 

324@with_exit_code() 

325def self_update(version: str = None) -> int: 

326 """ 

327 Update `su6` to the latest (stable) version. 

328 

329 Args: 

330 version: (optional) specific version to update to 

331 """ 

332 python = sys.executable 

333 pip = local[python]["-m", "pip"] 

334 

335 try: 

336 pkg = "su6" 

337 if version: 

338 pkg = f"{pkg}=={version}" 

339 

340 args = ["install", "--upgrade", pkg] 

341 if state.verbosity >= 3: 

342 log_command(pip, args) 

343 

344 output = pip(*args) 

345 if state.verbosity > 2: 

346 info(output) 

347 match state.output_format: 

348 case "text": 

349 print(GREEN_CIRCLE, "self-update") 

350 # case json handled automatically by with_exit_code 

351 return 0 

352 except PlumbumError as e: 

353 if state.verbosity > 3: 

354 raise e 

355 elif state.verbosity > 2: 

356 warn(str(e)) 

357 match state.output_format: 

358 case "text": 

359 print(RED_CIRCLE, "self-update") 

360 # case json handled automatically by with_exit_code 

361 return 1 

362 

363 

364def version_callback() -> None: 

365 """ 

366 --version requested! 

367 """ 

368 match state.output_format: 

369 case "text": 

370 print(f"su6 Version: {__version__}") 

371 case "json": 

372 print_json({"version": __version__}) 

373 raise typer.Exit(0) 

374 

375 

376@app.callback(invoke_without_command=True) 

377def main( 

378 ctx: typer.Context, 

379 config: str = None, 

380 verbosity: Verbosity = DEFAULT_VERBOSITY, 

381 output_format: typing.Annotated[Format, typer.Option("--format")] = DEFAULT_FORMAT, 

382 version: bool = False, 

383) -> None: 

384 """ 

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

386 

387 Args: 

388 ctx: context to determine if a subcommand is passed, etc 

389 config: path to a different config toml file 

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

391 output_format: output format 

392 version: display current version? 

393 

394 """ 

395 state.load_config(config_file=config, verbosity=verbosity, output_format=output_format) 

396 if version: 

397 version_callback() 

398 

399 if not ctx.invoked_subcommand: 

400 warn("Missing subcommand. Try `su6 --help` for more info.") 

401 

402 

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

404 app()