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

173 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2024-03-20 17:04 +0100

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 configuraptor import Singleton 

13from plumbum import local 

14from plumbum.machines import LocalCommand 

15from rich import print 

16from typing_extensions import Never 

17 

18from .__about__ import __version__ 

19from .core import ( 

20 DEFAULT_BADGE, 

21 DEFAULT_FORMAT, 

22 DEFAULT_VERBOSITY, 

23 GREEN_CIRCLE, 

24 RED_CIRCLE, 

25 YELLOW_CIRCLE, 

26 ExitCodes, 

27 Format, 

28 PlumbumError, 

29 Verbosity, 

30 dump_tools_with_results, 

31 info, 

32 is_installed, 

33 log_command, 

34 print_json, 

35 run_tool, 

36 state, 

37 warn, 

38 with_exit_code, 

39) 

40from .plugins import include_plugins 

41 

42app = typer.Typer() 

43 

44include_plugins(app) 

45 

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

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

48 

49 

50@app.command() 

51@with_exit_code() 

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

53 """ 

54 Runs the Ruff Linter. 

55 

56 Args: 

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

58 

59 """ 

60 config = state.update_config(directory=directory) 

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

62 

63 

64@app.command() 

65@with_exit_code() 

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

67 """ 

68 Runs the Black code formatter. 

69 

70 Args: 

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

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

73 

74 """ 

75 config = state.update_config(directory=directory) 

76 

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

78 if not fix: 

79 args.append("--check") 

80 elif state.verbosity > 2: 

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

82 

83 return run_tool("black", *args) 

84 

85 

86@app.command() 

87@with_exit_code() 

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

89 """ 

90 Runs the import sort (isort) utility. 

91 

92 Args: 

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

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

95 

96 """ 

97 config = state.update_config(directory=directory) 

98 args = [config.directory] 

99 if not fix: 

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

101 elif state.verbosity > 2: 

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

103 

104 return run_tool("isort", *args) 

105 

106 

107@app.command() 

108@with_exit_code() 

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

110 """ 

111 Runs the mypy static type checker. 

112 

113 Args: 

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

115 

116 """ 

117 config = state.update_config(directory=directory) 

118 

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

120 

121 

122@app.command() 

123@with_exit_code() 

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

125 """ 

126 Runs the bandit security checker. 

127 

128 Args: 

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

130 

131 """ 

132 config = state.update_config(directory=directory) 

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

134 

135 

136@app.command() 

137@with_exit_code() 

138def pydocstyle(directory: T_directory = None, convention: str = None) -> int: 

139 """ 

140 Runs the pydocstyle docstring checker. 

141 

142 Args: 

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

144 convention: pep257, numpy, google. 

145 """ 

146 config = state.update_config(directory=directory, docstyle_convention=convention) 

147 

148 args = [config.directory] 

149 

150 if config.docstyle_convention: 

151 args.extend(["--convention", config.docstyle_convention]) 

152 

153 return run_tool("pydocstyle", *args) 

154 

155 

156@app.command(name="list") 

157@with_exit_code() 

158def list_tools() -> None: 

159 """ 

160 List tools that would run with 'su6 all'. 

161 """ 

162 config = state.update_config() 

163 all_tools = [ruff, black, mypy, bandit, isort, pydocstyle, pytest] 

164 all_plugin_tools = [_.wrapped for _ in state._registered_plugins.values() if _.what == "command"] 

165 tools_to_run = config.determine_which_to_run(all_tools) + config.determine_plugins_to_run("add_to_all") 

166 

167 output = {} 

168 for tool in all_tools + all_plugin_tools: 

169 tool_name = tool.__name__.replace("_", "-") 

170 

171 if state.output_format == "text": 

172 if tool not in tools_to_run: 

173 print(RED_CIRCLE, tool_name) 

174 elif not is_installed(tool_name): # pragma: no cover 

175 print(YELLOW_CIRCLE, tool_name) 

176 else: 

177 # tool in tools_to_run 

178 print(GREEN_CIRCLE, tool_name) 

179 

180 elif state.output_format == "json": 

181 output[tool_name] = tool in tools_to_run 

182 

183 if state.output_format == "json": 

184 print_json(output) 

185 

186 

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

188@with_exit_code() 

189def check_all( 

190 directory: T_directory = None, 

191 ignore_uninstalled: bool = False, 

192 stop_after_first_failure: bool = None, 

193 exclude: list[str] = None, 

194 # pytest: 

195 coverage: float = None, 

196 badge: bool = None, 

197) -> bool: 

198 """ 

199 Run all available checks. 

200 

201 Args: 

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

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

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

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

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

207 

208 exclude: choose extra services (in addition to config) to skip for this run. 

209 coverage: pass to pytest() 

210 badge: pass to pytest() 

211 

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

213 """ 

214 config = state.update_config( 

215 directory=directory, 

216 stop_after_first_failure=stop_after_first_failure, 

217 coverage=coverage, 

218 badge=badge, 

219 ) 

220 

221 ignored_exit_codes = set() 

222 if ignore_uninstalled: 

223 ignored_exit_codes.add(ExitCodes.command_not_found) 

224 

225 tools = [ruff, black, mypy, bandit, isort, pydocstyle, pytest] 

226 

227 tools = config.determine_which_to_run(tools, exclude) + config.determine_plugins_to_run("add_to_all", exclude) 

228 

229 exit_codes = [] 

230 for tool in tools: 

231 a = [directory] 

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

233 

234 if tool is pytest: # pragma: no cover 

235 kw["coverage"] = config.coverage 

236 kw["badge"] = config.badge 

237 

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

239 exit_codes.append(result) 

240 if config.stop_after_first_failure and result != 0: 

241 break 

242 

243 if state.output_format == "json": 

244 dump_tools_with_results(tools, exit_codes) 

245 

246 return any(exit_codes) 

247 

248 

249@app.command() 

250@with_exit_code() 

251def pytest( 

252 directory: T_directory = None, 

253 html: bool = False, 

254 json: bool = False, 

255 coverage: int = None, 

256 badge: bool = None, 

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

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

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

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

261) -> int: # pragma: no cover 

262 """ 

263 Runs all pytests. 

264 

265 Args: 

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

267 html: generate HTML coverage output? 

268 json: generate JSON coverage output? 

269 coverage: threshold for coverage (in %) 

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

271 

272 k: pytest -k <str> option (run specific tests) 

273 s: pytest -s option (show output) 

274 v: pytest -v option (verbose) 

275 x: pytest -x option (stop after first failure) 

276 

277 Example: 

278 > su6 pytest --coverage 50 

279 if any checks fail: exit 1 and red circle 

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

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

282 

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

284 """ 

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

286 

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

288 # not None but still check cov 

289 config.coverage = 0 

290 

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

292 

293 if config.coverage is not None: 

294 # json output required! 

295 json = True 

296 

297 if k: 

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

299 if s: 

300 args.append("-s") 

301 if v: 

302 args.append("-v") 

303 if x: 

304 args.append("-x") 

305 

306 if html: 

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

308 

309 if json: 

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

311 

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

313 

314 if config.coverage is not None: 

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

316 data = json_load(f) 

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

318 

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

320 exit_code = percent_covered < config.coverage 

321 circle = RED_CIRCLE if exit_code else GREEN_CIRCLE 

322 if state.output_format == "text": 

323 print(circle, "coverage") 

324 

325 if config.badge: 

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

327 # it's still True for some reason? 

328 config.badge = DEFAULT_BADGE 

329 

330 with contextlib.suppress(FileNotFoundError): 

331 os.remove(config.badge) 

332 

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

334 if state.verbosity > 2: 

335 info(result) 

336 

337 return exit_code 

338 

339 

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

341@with_exit_code() 

342def do_fix(directory: T_directory = None, ignore_uninstalled: bool = False, exclude: list[str] = None) -> bool: 

343 """ 

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

345 

346 Args: 

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

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

349 exclude: choose extra services (in addition to config) to skip for this run. 

350 

351 

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

353 """ 

354 config = state.update_config(directory=directory) 

355 

356 ignored_exit_codes = set() 

357 if ignore_uninstalled: 

358 ignored_exit_codes.add(ExitCodes.command_not_found) 

359 

360 tools = [isort, black] 

361 

362 tools = config.determine_which_to_run(tools, exclude) + config.determine_plugins_to_run("add_to_fix", exclude) 

363 

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

365 

366 if state.output_format == "json": 

367 dump_tools_with_results(tools, exit_codes) 

368 

369 return any(exit_codes) 

370 

371 

372@app.command() 

373@with_exit_code() 

374def plugins() -> None: # pragma: nocover 

375 """ 

376 List installed plugin modules. 

377 

378 """ 

379 modules = entry_points(group="su6") 

380 match state.output_format: 

381 case "text": 

382 if modules: 

383 print("Installed Plugins:") 

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

385 else: 

386 print("No Installed Plugins.") 

387 case "json": 

388 print_json( 

389 { 

390 _.name: { 

391 "name": _.name, 

392 "value": _.value, 

393 "group": _.group, 

394 } 

395 for _ in modules 

396 } 

397 ) 

398 

399 

400def _pip() -> LocalCommand: 

401 """ 

402 Return a `pip` command. 

403 """ 

404 python = sys.executable 

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

406 

407 

408@app.command() 

409@with_exit_code() 

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

411 """ 

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

413 

414 Args: 

415 version: (optional) specific version to update to 

416 """ 

417 pip = _pip() 

418 

419 try: 

420 pkg = "su6" 

421 if version: 

422 pkg = f"{pkg}=={version}" 

423 

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

425 if state.verbosity >= 3: 

426 log_command(pip, args) 

427 

428 output = pip(*args) 

429 if state.verbosity > 2: 

430 info(output) 

431 match state.output_format: 

432 case "text": 

433 print(GREEN_CIRCLE, "self-update") 

434 # case json handled automatically by with_exit_code 

435 return 0 

436 except PlumbumError as e: 

437 if state.verbosity > 3: 

438 raise e 

439 elif state.verbosity > 2: 

440 warn(str(e)) 

441 match state.output_format: 

442 case "text": 

443 print(RED_CIRCLE, "self-update") 

444 # case json handled automatically by with_exit_code 

445 return 1 

446 

447 

448def version_callback() -> Never: 

449 """ 

450 --version requested! 

451 """ 

452 match state.output_format: 

453 case "text": 

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

455 case "json": 

456 print_json({"version": __version__}) 

457 raise typer.Exit(0) 

458 

459 

460def show_config_callback() -> Never: 

461 """ 

462 --show-config requested! 

463 """ 

464 match state.output_format: 

465 case "text": 

466 print(state) 

467 case "json": 

468 print_json(asdict(state)) 

469 raise typer.Exit(0) 

470 

471 

472@app.callback(invoke_without_command=True) 

473def main( 

474 ctx: typer.Context, 

475 config: str = None, 

476 verbosity: Verbosity = DEFAULT_VERBOSITY, 

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

478 # stops the program: 

479 show_config: bool = False, 

480 version: bool = False, 

481) -> None: 

482 """ 

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

484 

485 Args: 

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

487 config: path to a different config toml file 

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

489 output_format: output format 

490 

491 show_config: display current configuration? 

492 version: display current version? 

493 

494 """ 

495 if state.config: 

496 # if a config already exists, it's outdated, so we clear it. 

497 # we don't clear everything since Plugin configs may be already cached. 

498 Singleton.clear(state.config) 

499 

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

501 

502 if show_config: 

503 show_config_callback() 

504 elif version: 

505 version_callback() 

506 elif not ctx.invoked_subcommand: 

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

508 # else: just continue 

509 

510 

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

512 app()