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

154 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-01 14:32 +0200

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

2import contextlib 

3import math 

4import os 

5import sys 

6import typing 

7from dataclasses import asdict 

8from json import load as json_load 

9 

10import typer 

11from plumbum import local 

12from plumbum.commands.processes import CommandNotFound, ProcessExecutionError 

13from rich import print 

14 

15from .__about__ import __version__ 

16from .core import ( 

17 DEFAULT_BADGE, 

18 DEFAULT_FORMAT, 

19 DEFAULT_VERBOSITY, 

20 EXIT_CODE_COMMAND_NOT_FOUND, 

21 EXIT_CODE_ERROR, 

22 EXIT_CODE_SUCCESS, 

23 GREEN_CIRCLE, 

24 RED_CIRCLE, 

25 YELLOW_CIRCLE, 

26 Format, 

27 PlumbumError, 

28 Verbosity, 

29 dump_tools_with_results, 

30 info, 

31 log_cmd_output, 

32 log_command, 

33 print_json, 

34 state, 

35 warn, 

36 with_exit_code, 

37) 

38 

39app = typer.Typer() 

40 

41 

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

43 """ 

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

45 

46 Args: 

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

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

49 """ 

50 try: 

51 cmd = local[tool] 

52 

53 if state.verbosity >= 3: 

54 log_command(cmd, args) 

55 

56 result = cmd(*args) 

57 

58 if state.output_format == "text": 

59 print(GREEN_CIRCLE, tool) 

60 

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

62 log_cmd_output(result) 

63 

64 return EXIT_CODE_SUCCESS # success 

65 except CommandNotFound: # pragma: no cover 

66 if state.verbosity > 2: 

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

68 

69 if state.output_format == "text": 

70 print(YELLOW_CIRCLE, tool) 

71 

72 return EXIT_CODE_COMMAND_NOT_FOUND # command not found 

73 except ProcessExecutionError as e: 

74 if state.output_format == "text": 

75 print(RED_CIRCLE, tool) 

76 

77 if state.verbosity > 1: 

78 log_cmd_output(e.stdout, e.stderr) 

79 return EXIT_CODE_ERROR # general error 

80 

81 

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

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

84 

85 

86@app.command() 

87@with_exit_code() 

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

89 """ 

90 Runs the Ruff Linter. 

91 

92 Args: 

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

94 

95 """ 

96 config = state.update_config(directory=directory) 

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

98 

99 

100@app.command() 

101@with_exit_code() 

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

103 """ 

104 Runs the Black code formatter. 

105 

106 Args: 

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

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

109 

110 """ 

111 config = state.update_config(directory=directory) 

112 

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

114 if not fix: 

115 args.append("--check") 

116 elif state.verbosity > 2: 

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

118 

119 return _check_tool("black", *args) 

120 

121 

122@app.command() 

123@with_exit_code() 

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

125 """ 

126 Runs the import sort (isort) utility. 

127 

128 Args: 

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

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

131 

132 """ 

133 config = state.update_config(directory=directory) 

134 args = [config.directory] 

135 if not fix: 

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

137 elif state.verbosity > 2: 

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

139 

140 return _check_tool("isort", *args) 

141 

142 

143@app.command() 

144@with_exit_code() 

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

146 """ 

147 Runs the mypy static type checker. 

148 

149 Args: 

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

151 

152 """ 

153 config = state.update_config(directory=directory) 

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

155 

156 

157@app.command() 

158@with_exit_code() 

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

160 """ 

161 Runs the bandit security checker. 

162 

163 Args: 

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

165 

166 """ 

167 config = state.update_config(directory=directory) 

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

169 

170 

171@app.command() 

172@with_exit_code() 

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

174 """ 

175 Runs the pydocstyle docstring checker. 

176 

177 Args: 

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

179 

180 """ 

181 config = state.update_config(directory=directory) 

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

183 

184 

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

186@with_exit_code() 

187def check_all( 

188 directory: T_directory = None, 

189 ignore_uninstalled: bool = False, 

190 stop_after_first_failure: bool = None, 

191 # pytest: 

192 coverage: float = None, 

193 badge: bool = None, 

194) -> bool: 

195 """ 

196 Run all available checks. 

197 

198 Args: 

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

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

201 stop_after_first_failure: by default, the tool continues to run each test. 

202 But if you only want to know if everything passes, 

203 you could set this flag (or in the config toml) to stop early. 

204 

205 coverage: pass to pytest() 

206 badge: pass to pytest() 

207 

208 `def all()` is not allowed since this overshadows a builtin 

209 """ 

210 config = state.update_config( 

211 directory=directory, 

212 stop_after_first_failure=stop_after_first_failure, 

213 coverage=coverage, 

214 badge=badge, 

215 ) 

216 

217 ignored_exit_codes = set() 

218 if ignore_uninstalled: 

219 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND) 

220 

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

222 

223 exit_codes = [] 

224 for tool in tools: 

225 a = [directory] 

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

227 

228 if tool is pytest: # pragma: no cover 

229 kw["coverage"] = config.coverage 

230 kw["badge"] = config.badge 

231 

232 result = tool(*a, **kw) 

233 exit_codes.append(result) 

234 if config.stop_after_first_failure and result != 0: 

235 break 

236 

237 if state.output_format == "json": 

238 dump_tools_with_results(tools, exit_codes) 

239 

240 return any(exit_codes) 

241 

242 

243@app.command() 

244@with_exit_code() 

245def pytest( 

246 directory: T_directory = None, 

247 html: bool = False, 

248 json: bool = False, 

249 coverage: int = None, 

250 badge: bool = None, 

251) -> int: # pragma: no cover 

252 """ 

253 Runs all pytests. 

254 

255 Args: 

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

257 html: generate HTML coverage output? 

258 json: generate JSON coverage output? 

259 coverage: threshold for coverage (in %) 

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

261 

262 Example: 

263 > su6 pytest --coverage 50 

264 if any checks fail: exit 1 and red circle 

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

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

267 

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

269 """ 

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

271 

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

273 # not None but still check cov 

274 config.coverage = 0 

275 

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

277 

278 if config.coverage is not None: 

279 # json output required! 

280 json = True 

281 

282 if html: 

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

284 

285 if json: 

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

287 

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

289 

290 if config.coverage is not None: 

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

292 data = json_load(f) 

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

294 

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

296 exit_code = percent_covered < config.coverage 

297 circle = RED_CIRCLE if exit_code else GREEN_CIRCLE 

298 if state.output_format == "text": 

299 print(circle, "coverage") 

300 

301 if config.badge: 

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

303 # it's still True for some reason? 

304 config.badge = DEFAULT_BADGE 

305 

306 with contextlib.suppress(FileNotFoundError): 

307 os.remove(config.badge) 

308 

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

310 if state.verbosity > 2: 

311 info(result) 

312 

313 return exit_code 

314 

315 

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

317@with_exit_code() 

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

319 """ 

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

321 

322 Args: 

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

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

325 

326 `def fix()` is not recommended because other commands have 'fix' as an argument so those names would collide. 

327 """ 

328 config = state.update_config(directory=directory) 

329 

330 ignored_exit_codes = set() 

331 if ignore_uninstalled: 

332 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND) 

333 

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

335 

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

337 

338 if state.output_format == "json": 

339 dump_tools_with_results(tools, exit_codes) 

340 

341 return any(exit_codes) 

342 

343 

344@app.command() 

345@with_exit_code() 

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

347 """ 

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

349 

350 Args: 

351 version: (optional) specific version to update to 

352 """ 

353 python = sys.executable 

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

355 

356 try: 

357 pkg = "su6" 

358 if version: 

359 pkg = f"{pkg}=={version}" 

360 

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

362 if state.verbosity >= 3: 

363 log_command(pip, args) 

364 

365 output = pip(*args) 

366 if state.verbosity > 2: 

367 info(output) 

368 match state.output_format: 

369 case "text": 

370 print(GREEN_CIRCLE, "self-update") 

371 # case json handled automatically by with_exit_code 

372 return 0 

373 except PlumbumError as e: 

374 if state.verbosity > 3: 

375 raise e 

376 elif state.verbosity > 2: 

377 warn(str(e)) 

378 match state.output_format: 

379 case "text": 

380 print(RED_CIRCLE, "self-update") 

381 # case json handled automatically by with_exit_code 

382 return 1 

383 

384 

385def version_callback() -> typing.Never: 

386 """ 

387 --version requested! 

388 """ 

389 match state.output_format: 

390 case "text": 

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

392 case "json": 

393 print_json({"version": __version__}) 

394 raise typer.Exit(0) 

395 

396 

397def show_config_callback() -> typing.Never: 

398 """ 

399 --show-config requested! 

400 """ 

401 match state.output_format: 

402 case "text": 

403 print(state) 

404 case "json": 

405 print_json(asdict(state)) 

406 raise typer.Exit(0) 

407 

408 

409@app.callback(invoke_without_command=True) 

410def main( 

411 ctx: typer.Context, 

412 config: str = None, 

413 verbosity: Verbosity = DEFAULT_VERBOSITY, 

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

415 # stops the program: 

416 show_config: bool = False, 

417 version: bool = False, 

418) -> None: 

419 """ 

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

421 

422 Args: 

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

424 config: path to a different config toml file 

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

426 output_format: output format 

427 

428 show_config: display current configuration? 

429 version: display current version? 

430 

431 """ 

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

433 

434 if show_config: 

435 show_config_callback() 

436 elif version: 

437 version_callback() 

438 elif not ctx.invoked_subcommand: 

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

440 # else: just continue 

441 

442 

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

444 app()