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

170 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-10-09 14:16 +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 

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

118 

119 

120@app.command() 

121@with_exit_code() 

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

123 """ 

124 Runs the bandit security checker. 

125 

126 Args: 

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

128 

129 """ 

130 config = state.update_config(directory=directory) 

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

132 

133 

134@app.command() 

135@with_exit_code() 

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

137 """ 

138 Runs the pydocstyle docstring checker. 

139 

140 Args: 

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

142 

143 """ 

144 config = state.update_config(directory=directory) 

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

146 

147 

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

149@with_exit_code() 

150def list_tools() -> None: 

151 """ 

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

153 """ 

154 config = state.update_config() 

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

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

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

158 

159 output = {} 

160 for tool in all_tools + all_plugin_tools: 

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

162 

163 if state.output_format == "text": 

164 if tool in tools_to_run: 

165 print(GREEN_CIRCLE, tool_name) 

166 else: 

167 print(RED_CIRCLE, tool_name) 

168 

169 elif state.output_format == "json": 

170 output[tool_name] = tool in tools_to_run 

171 

172 if state.output_format == "json": 

173 print_json(output) 

174 

175 

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

177@with_exit_code() 

178def check_all( 

179 directory: T_directory = None, 

180 ignore_uninstalled: bool = False, 

181 stop_after_first_failure: bool = None, 

182 exclude: list[str] = None, 

183 # pytest: 

184 coverage: float = None, 

185 badge: bool = None, 

186) -> bool: 

187 """ 

188 Run all available checks. 

189 

190 Args: 

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

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

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

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

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

196 

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

198 coverage: pass to pytest() 

199 badge: pass to pytest() 

200 

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

202 """ 

203 config = state.update_config( 

204 directory=directory, 

205 stop_after_first_failure=stop_after_first_failure, 

206 coverage=coverage, 

207 badge=badge, 

208 ) 

209 

210 ignored_exit_codes = set() 

211 if ignore_uninstalled: 

212 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND) 

213 

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

215 

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

217 

218 exit_codes = [] 

219 for tool in tools: 

220 a = [directory] 

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

222 

223 if tool is pytest: # pragma: no cover 

224 kw["coverage"] = config.coverage 

225 kw["badge"] = config.badge 

226 

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

228 exit_codes.append(result) 

229 if config.stop_after_first_failure and result != 0: 

230 break 

231 

232 if state.output_format == "json": 

233 dump_tools_with_results(tools, exit_codes) 

234 

235 return any(exit_codes) 

236 

237 

238@app.command() 

239@with_exit_code() 

240def pytest( 

241 directory: T_directory = None, 

242 html: bool = False, 

243 json: bool = False, 

244 coverage: int = None, 

245 badge: bool = None, 

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

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

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

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

250) -> int: # pragma: no cover 

251 """ 

252 Runs all pytests. 

253 

254 Args: 

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

256 html: generate HTML coverage output? 

257 json: generate JSON coverage output? 

258 coverage: threshold for coverage (in %) 

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

260 

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

262 s: pytest -s option (show output) 

263 v: pytest -v option (verbose) 

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

265 

266 Example: 

267 > su6 pytest --coverage 50 

268 if any checks fail: exit 1 and red circle 

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

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

271 

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

273 """ 

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

275 

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

277 # not None but still check cov 

278 config.coverage = 0 

279 

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

281 

282 if config.coverage is not None: 

283 # json output required! 

284 json = True 

285 

286 if k: 

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

288 if s: 

289 args.append("-s") 

290 if v: 

291 args.append("-v") 

292 if x: 

293 args.append("-x") 

294 

295 if html: 

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

297 

298 if json: 

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

300 

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

302 

303 if config.coverage is not None: 

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

305 data = json_load(f) 

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

307 

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

309 exit_code = percent_covered < config.coverage 

310 circle = RED_CIRCLE if exit_code else GREEN_CIRCLE 

311 if state.output_format == "text": 

312 print(circle, "coverage") 

313 

314 if config.badge: 

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

316 # it's still True for some reason? 

317 config.badge = DEFAULT_BADGE 

318 

319 with contextlib.suppress(FileNotFoundError): 

320 os.remove(config.badge) 

321 

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

323 if state.verbosity > 2: 

324 info(result) 

325 

326 return exit_code 

327 

328 

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

330@with_exit_code() 

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

332 """ 

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

334 

335 Args: 

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

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

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

339 

340 

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

342 """ 

343 config = state.update_config(directory=directory) 

344 

345 ignored_exit_codes = set() 

346 if ignore_uninstalled: 

347 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND) 

348 

349 tools = [isort, black] 

350 

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

352 

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

354 

355 if state.output_format == "json": 

356 dump_tools_with_results(tools, exit_codes) 

357 

358 return any(exit_codes) 

359 

360 

361@app.command() 

362@with_exit_code() 

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

364 """ 

365 List installed plugin modules. 

366 

367 """ 

368 modules = entry_points(group="su6") 

369 match state.output_format: 

370 case "text": 

371 if modules: 

372 print("Installed Plugins:") 

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

374 else: 

375 print("No Installed Plugins.") 

376 case "json": 

377 print_json( 

378 { 

379 _.name: { 

380 "name": _.name, 

381 "value": _.value, 

382 "group": _.group, 

383 } 

384 for _ in modules 

385 } 

386 ) 

387 

388 

389def _pip() -> LocalCommand: 

390 """ 

391 Return a `pip` command. 

392 """ 

393 python = sys.executable 

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

395 

396 

397@app.command() 

398@with_exit_code() 

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

400 """ 

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

402 

403 Args: 

404 version: (optional) specific version to update to 

405 """ 

406 pip = _pip() 

407 

408 try: 

409 pkg = "su6" 

410 if version: 

411 pkg = f"{pkg}=={version}" 

412 

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

414 if state.verbosity >= 3: 

415 log_command(pip, args) 

416 

417 output = pip(*args) 

418 if state.verbosity > 2: 

419 info(output) 

420 match state.output_format: 

421 case "text": 

422 print(GREEN_CIRCLE, "self-update") 

423 # case json handled automatically by with_exit_code 

424 return 0 

425 except PlumbumError as e: 

426 if state.verbosity > 3: 

427 raise e 

428 elif state.verbosity > 2: 

429 warn(str(e)) 

430 match state.output_format: 

431 case "text": 

432 print(RED_CIRCLE, "self-update") 

433 # case json handled automatically by with_exit_code 

434 return 1 

435 

436 

437def version_callback() -> Never: 

438 """ 

439 --version requested! 

440 """ 

441 match state.output_format: 

442 case "text": 

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

444 case "json": 

445 print_json({"version": __version__}) 

446 raise typer.Exit(0) 

447 

448 

449def show_config_callback() -> Never: 

450 """ 

451 --show-config requested! 

452 """ 

453 match state.output_format: 

454 case "text": 

455 print(state) 

456 case "json": 

457 print_json(asdict(state)) 

458 raise typer.Exit(0) 

459 

460 

461@app.callback(invoke_without_command=True) 

462def main( 

463 ctx: typer.Context, 

464 config: str = None, 

465 verbosity: Verbosity = DEFAULT_VERBOSITY, 

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

467 # stops the program: 

468 show_config: bool = False, 

469 version: bool = False, 

470) -> None: 

471 """ 

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

473 

474 Args: 

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

476 config: path to a different config toml file 

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

478 output_format: output format 

479 

480 show_config: display current configuration? 

481 version: display current version? 

482 

483 """ 

484 if state.config: 

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

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

487 Singleton.clear(state.config) 

488 

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

490 

491 if show_config: 

492 show_config_callback() 

493 elif version: 

494 version_callback() 

495 elif not ctx.invoked_subcommand: 

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

497 # else: just continue 

498 

499 

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

501 app()