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

156 statements  

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

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

2import contextlib 

3import math 

4import os 

5import sys 

6import typing 

7from dataclasses import asdict 

8from importlib.metadata import entry_points 

9from json import load as json_load 

10 

11import typer 

12from plumbum import local 

13from plumbum.machines import LocalCommand 

14from rich import print 

15 

16from .__about__ import __version__ 

17from .core import ( 

18 DEFAULT_BADGE, 

19 DEFAULT_FORMAT, 

20 DEFAULT_VERBOSITY, 

21 EXIT_CODE_COMMAND_NOT_FOUND, 

22 GREEN_CIRCLE, 

23 RED_CIRCLE, 

24 Format, 

25 PlumbumError, 

26 Singleton, 

27 Verbosity, 

28 dump_tools_with_results, 

29 info, 

30 log_command, 

31 print_json, 

32 run_tool, 

33 state, 

34 warn, 

35 with_exit_code, 

36) 

37from .plugins import include_plugins 

38 

39app = typer.Typer() 

40 

41include_plugins(app) 

42 

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

44T_directory: typing.TypeAlias = typing.Annotated[str, typer.Argument()] 

45 

46 

47@app.command() 

48@with_exit_code() 

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

50 """ 

51 Runs the Ruff Linter. 

52 

53 Args: 

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

55 

56 """ 

57 config = state.update_config(directory=directory) 

58 return run_tool("ruff", config.directory) 

59 

60 

61@app.command() 

62@with_exit_code() 

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

64 """ 

65 Runs the Black code formatter. 

66 

67 Args: 

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

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

70 

71 """ 

72 config = state.update_config(directory=directory) 

73 

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

75 if not fix: 

76 args.append("--check") 

77 elif state.verbosity > 2: 

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

79 

80 return run_tool("black", *args) 

81 

82 

83@app.command() 

84@with_exit_code() 

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

86 """ 

87 Runs the import sort (isort) utility. 

88 

89 Args: 

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

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

92 

93 """ 

94 config = state.update_config(directory=directory) 

95 args = [config.directory] 

96 if not fix: 

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

98 elif state.verbosity > 2: 

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

100 

101 return run_tool("isort", *args) 

102 

103 

104@app.command() 

105@with_exit_code() 

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

107 """ 

108 Runs the mypy static type checker. 

109 

110 Args: 

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

112 

113 """ 

114 config = state.update_config(directory=directory) 

115 return run_tool("mypy", config.directory) 

116 

117 

118@app.command() 

119@with_exit_code() 

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

121 """ 

122 Runs the bandit security checker. 

123 

124 Args: 

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

126 

127 """ 

128 config = state.update_config(directory=directory) 

129 return run_tool("bandit", "-r", "-c", config.pyproject, config.directory) 

130 

131 

132@app.command() 

133@with_exit_code() 

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

135 """ 

136 Runs the pydocstyle docstring checker. 

137 

138 Args: 

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

140 

141 """ 

142 config = state.update_config(directory=directory) 

143 return run_tool("pydocstyle", config.directory) 

144 

145 

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

147@with_exit_code() 

148def check_all( 

149 directory: T_directory = None, 

150 ignore_uninstalled: bool = False, 

151 stop_after_first_failure: bool = None, 

152 # pytest: 

153 coverage: float = None, 

154 badge: bool = None, 

155) -> bool: 

156 """ 

157 Run all available checks. 

158 

159 Args: 

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

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

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

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

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

165 

166 coverage: pass to pytest() 

167 badge: pass to pytest() 

168 

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

170 """ 

171 config = state.update_config( 

172 directory=directory, 

173 stop_after_first_failure=stop_after_first_failure, 

174 coverage=coverage, 

175 badge=badge, 

176 ) 

177 

178 ignored_exit_codes = set() 

179 if ignore_uninstalled: 

180 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND) 

181 

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

183 

184 exit_codes = [] 

185 for tool in tools: 

186 a = [directory] 

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

188 

189 if tool is pytest: # pragma: no cover 

190 kw["coverage"] = config.coverage 

191 kw["badge"] = config.badge 

192 

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

194 exit_codes.append(result) 

195 if config.stop_after_first_failure and result != 0: 

196 break 

197 

198 if state.output_format == "json": 

199 dump_tools_with_results(tools, exit_codes) 

200 

201 return any(exit_codes) 

202 

203 

204@app.command() 

205@with_exit_code() 

206def pytest( 

207 directory: T_directory = None, 

208 html: bool = False, 

209 json: bool = False, 

210 coverage: int = None, 

211 badge: bool = None, 

212 k: typing.Annotated[str, typer.Option("-k")] = None, # fw to pytest 

213 s: typing.Annotated[bool, typer.Option("-s")] = False, # fw to pytest 

214) -> int: # pragma: no cover 

215 """ 

216 Runs all pytests. 

217 

218 Args: 

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

220 html: generate HTML coverage output? 

221 json: generate JSON coverage output? 

222 coverage: threshold for coverage (in %) 

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

224 

225 k: pytest -k <str> option 

226 s: pytest -s option 

227 

228 Example: 

229 > su6 pytest --coverage 50 

230 if any checks fail: exit 1 and red circle 

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

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

233 

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

235 """ 

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

237 

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

239 # not None but still check cov 

240 config.coverage = 0 

241 

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

243 

244 if config.coverage is not None: 

245 # json output required! 

246 json = True 

247 

248 if k: 

249 args.extend(["-k", k]) 

250 if s: 

251 args.append("-s") 

252 

253 if html: 

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

255 

256 if json: 

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

258 

259 exit_code = run_tool("pytest", *args) 

260 

261 if config.coverage is not None: 

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

263 data = json_load(f) 

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

265 

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

267 exit_code = percent_covered < config.coverage 

268 circle = RED_CIRCLE if exit_code else GREEN_CIRCLE 

269 if state.output_format == "text": 

270 print(circle, "coverage") 

271 

272 if config.badge: 

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

274 # it's still True for some reason? 

275 config.badge = DEFAULT_BADGE 

276 

277 with contextlib.suppress(FileNotFoundError): 

278 os.remove(config.badge) 

279 

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

281 if state.verbosity > 2: 

282 info(result) 

283 

284 return exit_code 

285 

286 

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

288@with_exit_code() 

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

290 """ 

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

292 

293 Args: 

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

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

296 

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

298 """ 

299 config = state.update_config(directory=directory) 

300 

301 ignored_exit_codes = set() 

302 if ignore_uninstalled: 

303 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND) 

304 

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

306 

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

308 

309 if state.output_format == "json": 

310 dump_tools_with_results(tools, exit_codes) 

311 

312 return any(exit_codes) 

313 

314 

315@app.command() 

316@with_exit_code() 

317def plugins() -> None: 

318 """ 

319 List installed plugin modules. 

320 

321 """ 

322 modules = entry_points(group="su6") 

323 match state.output_format: 

324 case "text": 

325 if modules: 

326 print("Installed Plugins:") 

327 [print("-", _) for _ in modules] 

328 else: # pragma: nocover 

329 print("No Installed Plugins.") 

330 case "json": 

331 print_json( 

332 { 

333 _.name: { 

334 "name": _.name, 

335 "value": _.value, 

336 "group": _.group, 

337 } 

338 for _ in modules 

339 } 

340 ) 

341 

342 

343def _pip() -> LocalCommand: 

344 """ 

345 Return a `pip` command. 

346 """ 

347 python = sys.executable 

348 return local[python]["-m", "pip"] 

349 

350 

351@app.command() 

352@with_exit_code() 

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

354 """ 

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

356 

357 Args: 

358 version: (optional) specific version to update to 

359 """ 

360 pip = _pip() 

361 

362 try: 

363 pkg = "su6" 

364 if version: 

365 pkg = f"{pkg}=={version}" 

366 

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

368 if state.verbosity >= 3: 

369 log_command(pip, args) 

370 

371 output = pip(*args) 

372 if state.verbosity > 2: 

373 info(output) 

374 match state.output_format: 

375 case "text": 

376 print(GREEN_CIRCLE, "self-update") 

377 # case json handled automatically by with_exit_code 

378 return 0 

379 except PlumbumError as e: 

380 if state.verbosity > 3: 

381 raise e 

382 elif state.verbosity > 2: 

383 warn(str(e)) 

384 match state.output_format: 

385 case "text": 

386 print(RED_CIRCLE, "self-update") 

387 # case json handled automatically by with_exit_code 

388 return 1 

389 

390 

391def version_callback() -> typing.Never: 

392 """ 

393 --version requested! 

394 """ 

395 match state.output_format: 

396 case "text": 

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

398 case "json": 

399 print_json({"version": __version__}) 

400 raise typer.Exit(0) 

401 

402 

403def show_config_callback() -> typing.Never: 

404 """ 

405 --show-config requested! 

406 """ 

407 match state.output_format: 

408 case "text": 

409 print(state) 

410 case "json": 

411 print_json(asdict(state)) 

412 raise typer.Exit(0) 

413 

414 

415@app.callback(invoke_without_command=True) 

416def main( 

417 ctx: typer.Context, 

418 config: str = None, 

419 verbosity: Verbosity = DEFAULT_VERBOSITY, 

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

421 # stops the program: 

422 show_config: bool = False, 

423 version: bool = False, 

424) -> None: 

425 """ 

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

427 

428 Args: 

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

430 config: path to a different config toml file 

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

432 output_format: output format 

433 

434 show_config: display current configuration? 

435 version: display current version? 

436 

437 """ 

438 Singleton.clear() 

439 

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

441 

442 if show_config: 

443 show_config_callback() 

444 elif version: 

445 version_callback() 

446 elif not ctx.invoked_subcommand: 

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

448 # else: just continue 

449 

450 

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

452 app()