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

170 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-07-17 13:55 +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 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 EXIT_CODE_COMMAND_NOT_FOUND, 

24 GREEN_CIRCLE, 

25 RED_CIRCLE, 

26 Format, 

27 PlumbumError, 

28 Verbosity, 

29 dump_tools_with_results, 

30 info, 

31 log_command, 

32 print_json, 

33 run_tool, 

34 state, 

35 warn, 

36 with_exit_code, 

37) 

38from .plugins import include_plugins 

39 

40app = typer.Typer() 

41 

42include_plugins(app) 

43 

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

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

46 

47 

48@app.command() 

49@with_exit_code() 

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

51 """ 

52 Runs the Ruff Linter. 

53 

54 Args: 

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

56 

57 """ 

58 config = state.update_config(directory=directory) 

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

60 

61 

62@app.command() 

63@with_exit_code() 

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

65 """ 

66 Runs the Black code formatter. 

67 

68 Args: 

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

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

71 

72 """ 

73 config = state.update_config(directory=directory) 

74 

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

76 if not fix: 

77 args.append("--check") 

78 elif state.verbosity > 2: 

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

80 

81 return run_tool("black", *args) 

82 

83 

84@app.command() 

85@with_exit_code() 

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

87 """ 

88 Runs the import sort (isort) utility. 

89 

90 Args: 

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

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

93 

94 """ 

95 config = state.update_config(directory=directory) 

96 args = [config.directory] 

97 if not fix: 

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

99 elif state.verbosity > 2: 

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

101 

102 return run_tool("isort", *args) 

103 

104 

105@app.command() 

106@with_exit_code() 

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

108 """ 

109 Runs the mypy static type checker. 

110 

111 Args: 

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

113 

114 """ 

115 config = state.update_config(directory=directory) 

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

117 

118 

119@app.command() 

120@with_exit_code() 

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

122 """ 

123 Runs the bandit security checker. 

124 

125 Args: 

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

127 

128 """ 

129 config = state.update_config(directory=directory) 

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

131 

132 

133@app.command() 

134@with_exit_code() 

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

136 """ 

137 Runs the pydocstyle docstring checker. 

138 

139 Args: 

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

141 

142 """ 

143 config = state.update_config(directory=directory) 

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

145 

146 

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

148@with_exit_code() 

149def list_tools() -> None: 

150 """ 

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

152 """ 

153 config = state.update_config() 

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

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

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

157 

158 output = {} 

159 for tool in all_tools + all_plugin_tools: 

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

161 

162 if state.output_format == "text": 

163 if tool in tools_to_run: 

164 print(GREEN_CIRCLE, tool_name) 

165 else: 

166 print(RED_CIRCLE, tool_name) 

167 

168 elif state.output_format == "json": 

169 output[tool_name] = tool in tools_to_run 

170 

171 if state.output_format == "json": 

172 print_json(output) 

173 

174 

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

176@with_exit_code() 

177def check_all( 

178 directory: T_directory = None, 

179 ignore_uninstalled: bool = False, 

180 stop_after_first_failure: bool = None, 

181 # pytest: 

182 coverage: float = None, 

183 badge: bool = None, 

184) -> bool: 

185 """ 

186 Run all available checks. 

187 

188 Args: 

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

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

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

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

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

194 

195 coverage: pass to pytest() 

196 badge: pass to pytest() 

197 

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

199 """ 

200 config = state.update_config( 

201 directory=directory, 

202 stop_after_first_failure=stop_after_first_failure, 

203 coverage=coverage, 

204 badge=badge, 

205 ) 

206 

207 ignored_exit_codes = set() 

208 if ignore_uninstalled: 

209 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND) 

210 

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

212 

213 tools = config.determine_which_to_run(tools) + config.determine_plugins_to_run("add_to_all") 

214 

215 exit_codes = [] 

216 for tool in tools: 

217 a = [directory] 

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

219 

220 if tool is pytest: # pragma: no cover 

221 kw["coverage"] = config.coverage 

222 kw["badge"] = config.badge 

223 

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

225 exit_codes.append(result) 

226 if config.stop_after_first_failure and result != 0: 

227 break 

228 

229 if state.output_format == "json": 

230 dump_tools_with_results(tools, exit_codes) 

231 

232 return any(exit_codes) 

233 

234 

235@app.command() 

236@with_exit_code() 

237def pytest( 

238 directory: T_directory = None, 

239 html: bool = False, 

240 json: bool = False, 

241 coverage: int = None, 

242 badge: bool = None, 

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

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

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

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

247) -> int: # pragma: no cover 

248 """ 

249 Runs all pytests. 

250 

251 Args: 

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

253 html: generate HTML coverage output? 

254 json: generate JSON coverage output? 

255 coverage: threshold for coverage (in %) 

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

257 

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

259 s: pytest -s option (show output) 

260 v: pytest -v option (verbose) 

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

262 

263 Example: 

264 > su6 pytest --coverage 50 

265 if any checks fail: exit 1 and red circle 

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

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

268 

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

270 """ 

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

272 

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

274 # not None but still check cov 

275 config.coverage = 0 

276 

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

278 

279 if config.coverage is not None: 

280 # json output required! 

281 json = True 

282 

283 if k: 

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

285 if s: 

286 args.append("-s") 

287 if v: 

288 args.append("-v") 

289 if x: 

290 args.append("-x") 

291 

292 if html: 

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

294 

295 if json: 

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

297 

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

299 

300 if config.coverage is not None: 

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

302 data = json_load(f) 

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

304 

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

306 exit_code = percent_covered < config.coverage 

307 circle = RED_CIRCLE if exit_code else GREEN_CIRCLE 

308 if state.output_format == "text": 

309 print(circle, "coverage") 

310 

311 if config.badge: 

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

313 # it's still True for some reason? 

314 config.badge = DEFAULT_BADGE 

315 

316 with contextlib.suppress(FileNotFoundError): 

317 os.remove(config.badge) 

318 

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

320 if state.verbosity > 2: 

321 info(result) 

322 

323 return exit_code 

324 

325 

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

327@with_exit_code() 

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

329 """ 

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

331 

332 Args: 

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

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

335 

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

337 """ 

338 config = state.update_config(directory=directory) 

339 

340 ignored_exit_codes = set() 

341 if ignore_uninstalled: 

342 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND) 

343 

344 tools = [isort, black] 

345 

346 tools = config.determine_which_to_run(tools) + config.determine_plugins_to_run("add_to_fix") 

347 

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

349 

350 if state.output_format == "json": 

351 dump_tools_with_results(tools, exit_codes) 

352 

353 return any(exit_codes) 

354 

355 

356@app.command() 

357@with_exit_code() 

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

359 """ 

360 List installed plugin modules. 

361 

362 """ 

363 modules = entry_points(group="su6") 

364 match state.output_format: 

365 case "text": 

366 if modules: 

367 print("Installed Plugins:") 

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

369 else: 

370 print("No Installed Plugins.") 

371 case "json": 

372 print_json( 

373 { 

374 _.name: { 

375 "name": _.name, 

376 "value": _.value, 

377 "group": _.group, 

378 } 

379 for _ in modules 

380 } 

381 ) 

382 

383 

384def _pip() -> LocalCommand: 

385 """ 

386 Return a `pip` command. 

387 """ 

388 python = sys.executable 

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

390 

391 

392@app.command() 

393@with_exit_code() 

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

395 """ 

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

397 

398 Args: 

399 version: (optional) specific version to update to 

400 """ 

401 pip = _pip() 

402 

403 try: 

404 pkg = "su6" 

405 if version: 

406 pkg = f"{pkg}=={version}" 

407 

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

409 if state.verbosity >= 3: 

410 log_command(pip, args) 

411 

412 output = pip(*args) 

413 if state.verbosity > 2: 

414 info(output) 

415 match state.output_format: 

416 case "text": 

417 print(GREEN_CIRCLE, "self-update") 

418 # case json handled automatically by with_exit_code 

419 return 0 

420 except PlumbumError as e: 

421 if state.verbosity > 3: 

422 raise e 

423 elif state.verbosity > 2: 

424 warn(str(e)) 

425 match state.output_format: 

426 case "text": 

427 print(RED_CIRCLE, "self-update") 

428 # case json handled automatically by with_exit_code 

429 return 1 

430 

431 

432def version_callback() -> Never: 

433 """ 

434 --version requested! 

435 """ 

436 match state.output_format: 

437 case "text": 

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

439 case "json": 

440 print_json({"version": __version__}) 

441 raise typer.Exit(0) 

442 

443 

444def show_config_callback() -> Never: 

445 """ 

446 --show-config requested! 

447 """ 

448 match state.output_format: 

449 case "text": 

450 print(state) 

451 case "json": 

452 print_json(asdict(state)) 

453 raise typer.Exit(0) 

454 

455 

456@app.callback(invoke_without_command=True) 

457def main( 

458 ctx: typer.Context, 

459 config: str = None, 

460 verbosity: Verbosity = DEFAULT_VERBOSITY, 

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

462 # stops the program: 

463 show_config: bool = False, 

464 version: bool = False, 

465) -> None: 

466 """ 

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

468 

469 Args: 

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

471 config: path to a different config toml file 

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

473 output_format: output format 

474 

475 show_config: display current configuration? 

476 version: display current version? 

477 

478 """ 

479 if state.config: 

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

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

482 Singleton.clear(state.config) 

483 

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

485 

486 if show_config: 

487 show_config_callback() 

488 elif version: 

489 version_callback() 

490 elif not ctx.invoked_subcommand: 

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

492 # else: just continue 

493 

494 

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

496 app()