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

169 statements  

« prev     ^ index     » next       coverage.py v7.8.1, created at 2025-09-19 22:55 +0200

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

2 

3import contextlib 

4import math 

5import os 

6import sys 

7import typing 

8from dataclasses import asdict 

9from importlib.metadata import entry_points 

10from json import load as json_load 

11 

12import typer 

13from configuraptor import Singleton 

14from plumbum import local 

15from plumbum.machines import LocalCommand 

16from rich import print 

17from typing_extensions import Never 

18 

19from .__about__ import __version__ 

20from .core import ( 

21 DEFAULT_BADGE, 

22 DEFAULT_FORMAT, 

23 DEFAULT_VERBOSITY, 

24 GREEN_CIRCLE, 

25 RED_CIRCLE, 

26 YELLOW_CIRCLE, 

27 ExitCodes, 

28 Format, 

29 PlumbumError, 

30 Verbosity, 

31 dump_tools_with_results, 

32 info, 

33 is_installed, 

34 log_command, 

35 print_json, 

36 run_tool, 

37 state, 

38 warn, 

39 with_exit_code, 

40) 

41from .plugins import include_plugins 

42 

43app = typer.Typer() 

44 

45include_plugins(app) 

46 

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

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

49 

50 

51@app.command() 

52@with_exit_code() 

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

54 """ 

55 Runs the Ruff Linter. 

56 

57 Args: 

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

59 

60 """ 

61 config = state.update_config(directory=directory) 

62 

63 return run_tool("ruff", "check", config.directory) 

64 

65 

66@app.command() 

67@with_exit_code() 

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

69 """ 

70 Runs the Black code formatter. 

71 

72 Args: 

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

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

75 

76 """ 

77 config = state.update_config(directory=directory) 

78 

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

80 if not fix: 

81 args.append("--check") 

82 elif state.verbosity > 2: 

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

84 

85 return run_tool("black", *args) 

86 

87 

88@app.command() 

89@with_exit_code() 

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

91 """ 

92 Runs the import sort (isort) utility. 

93 

94 Args: 

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

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

97 

98 """ 

99 config = state.update_config(directory=directory) 

100 args = [config.directory] 

101 if not fix: 

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

103 elif state.verbosity > 2: 

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

105 

106 return run_tool("isort", *args) 

107 

108 

109@app.command() 

110@with_exit_code() 

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

112 """ 

113 Runs the mypy static type checker. 

114 

115 Args: 

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

117 

118 """ 

119 config = state.update_config(directory=directory) 

120 

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

122 

123 

124@app.command() 

125@with_exit_code() 

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

127 """ 

128 Runs the bandit security checker. 

129 

130 Args: 

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

132 

133 """ 

134 config = state.update_config(directory=directory) 

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

136 

137 

138@app.command() 

139@with_exit_code() 

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

141 """ 

142 Runs the pydocstyle docstring checker. 

143 

144 Args: 

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

146 convention: pep257, numpy, google. 

147 """ 

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

149 

150 args = [config.directory] 

151 

152 if config.docstyle_convention: 

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

154 

155 return run_tool("pydocstyle", *args) 

156 

157 

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

159@with_exit_code() 

160def list_tools() -> None: 

161 """ 

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

163 """ 

164 config = state.update_config() 

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

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

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

168 

169 output = {} 

170 for tool in all_tools + all_plugin_tools: 

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

172 

173 if state.output_format == "text": 

174 if tool not in tools_to_run: 

175 print(RED_CIRCLE, tool_name) 

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

177 print(YELLOW_CIRCLE, tool_name) 

178 else: 

179 # tool in tools_to_run 

180 print(GREEN_CIRCLE, tool_name) 

181 

182 elif state.output_format == "json": 

183 output[tool_name] = tool in tools_to_run 

184 

185 if state.output_format == "json": 

186 print_json(output) 

187 

188 

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

190@with_exit_code() 

191def check_all( 

192 directory: T_directory = None, 

193 ignore_uninstalled: bool = False, 

194 stop_after_first_failure: bool = None, 

195 exclude: list[str] = None, 

196 # pytest: 

197 coverage: float = None, 

198 badge: bool = None, 

199) -> bool: 

200 """ 

201 Run all available checks. 

202 

203 Args: 

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

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

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

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

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

209 

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

211 coverage: pass to pytest() 

212 badge: pass to pytest() 

213 

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

215 """ 

216 config = state.update_config( 

217 directory=directory, 

218 stop_after_first_failure=stop_after_first_failure, 

219 coverage=coverage, 

220 badge=badge, 

221 ) 

222 

223 ignored_exit_codes = set() 

224 if ignore_uninstalled: 

225 ignored_exit_codes.add(ExitCodes.command_not_found) 

226 

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

228 

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

230 

231 exit_codes = [] 

232 for tool in tools: 

233 a = [directory] 

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

235 

236 if tool is pytest: # pragma: no cover 

237 kw["coverage"] = config.coverage 

238 kw["badge"] = config.badge 

239 

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

241 exit_codes.append(result) 

242 if config.stop_after_first_failure and result != 0: 

243 break 

244 

245 if state.output_format == "json": 

246 dump_tools_with_results(tools, exit_codes) 

247 

248 return any(exit_codes) 

249 

250 

251@app.command() 

252@with_exit_code() 

253def pytest( 

254 directory: T_directory = None, 

255 html: bool = False, 

256 json: bool = False, 

257 xml: bool = False, 

258 coverage: int = None, 

259 badge: bool = None, 

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

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

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

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

264) -> int: # pragma: no cover 

265 """ 

266 Runs all pytests. 

267 

268 Args: 

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

270 html: generate HTML coverage output? 

271 json: generate JSON coverage output? 

272 xml: generate XML coverage output? 

273 coverage: threshold for coverage (in %) 

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

275 

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

277 s: pytest -s option (show output) 

278 v: pytest -v option (verbose) 

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

280 

281 Example: 

282 > su6 pytest --coverage 50 

283 if any checks fail: exit 1 and red circle 

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

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

286 

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

288 """ 

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

290 

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

292 # not None but still check cov 

293 config.coverage = 0 

294 

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

296 

297 if config.coverage is not None: 

298 # json output required! 

299 json = True 

300 

301 if config.badge is not None: 

302 # xml output required! 

303 xml = True 

304 

305 if k: 

306 # -k for specific test 

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

308 if s: 

309 # -s for showing output 

310 args.append("-s") 

311 if v: 

312 # -v for verbose 

313 args.append("-v") 

314 if x: 

315 # -x for stopping after first failure 

316 args.append("-x") 

317 

318 if html: 

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

320 

321 if json: 

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

323 

324 if xml: 

325 args.extend(["--cov-report", "xml"]) 

326 

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

328 

329 if config.coverage is not None: 

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

331 data = json_load(f) 

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

333 

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

335 exit_code = percent_covered < config.coverage 

336 circle = RED_CIRCLE if exit_code else GREEN_CIRCLE 

337 if state.output_format == "text": 

338 print(circle, "coverage") 

339 

340 if config.badge: 

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

342 # it's still True for some reason? 

343 config.badge = DEFAULT_BADGE 

344 

345 with contextlib.suppress(FileNotFoundError): 

346 os.remove(config.badge) 

347 

348 result = local["genbadge"]("coverage", "-i", "coverage.xml", "-o", config.badge) 

349 if state.verbosity > 2: 

350 info(result) 

351 

352 return exit_code 

353 

354 

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

356@with_exit_code() 

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

358 """ 

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

360 

361 Args: 

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

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

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

365 

366 

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

368 """ 

369 config = state.update_config(directory=directory) 

370 

371 ignored_exit_codes = set() 

372 if ignore_uninstalled: 

373 ignored_exit_codes.add(ExitCodes.command_not_found) 

374 

375 tools = [isort, black] 

376 

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

378 

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

380 

381 if state.output_format == "json": 

382 dump_tools_with_results(tools, exit_codes) 

383 

384 return any(exit_codes) 

385 

386 

387@app.command() 

388@with_exit_code() 

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

390 """ 

391 List installed plugin modules. 

392 

393 """ 

394 modules = entry_points(group="su6") 

395 match state.output_format: 

396 case "text": 

397 if modules: 

398 print("Installed Plugins:") 

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

400 else: 

401 print("No Installed Plugins.") 

402 case "json": 

403 print_json( 

404 { 

405 _.name: { 

406 "name": _.name, 

407 "value": _.value, 

408 "group": _.group, 

409 } 

410 for _ in modules 

411 } 

412 ) 

413 

414 

415def _pip() -> LocalCommand: 

416 """ 

417 Return a `pip` command. 

418 """ 

419 python = sys.executable 

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

421 

422 

423@app.command() 

424@with_exit_code() 

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

426 """ 

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

428 

429 Args: 

430 version: (optional) specific version to update to 

431 """ 

432 pip = _pip() 

433 

434 try: 

435 pkg = "su6" 

436 if version: 

437 pkg = f"{pkg}=={version}" 

438 

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

440 if state.verbosity >= 3: 

441 log_command(pip, args) 

442 

443 output = pip(*args) 

444 if state.verbosity > 2: 

445 info(output) 

446 match state.output_format: 

447 case "text": 

448 print(GREEN_CIRCLE, "self-update") 

449 # case json handled automatically by with_exit_code 

450 return 0 

451 except PlumbumError as e: 

452 if state.verbosity > 3: 

453 raise e 

454 elif state.verbosity > 2: 

455 warn(str(e)) 

456 match state.output_format: 

457 case "text": 

458 print(RED_CIRCLE, "self-update") 

459 # case json handled automatically by with_exit_code 

460 return 1 

461 

462 

463def version_callback() -> Never: 

464 """ 

465 --version requested! 

466 """ 

467 match state.output_format: 

468 case "text": 

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

470 case "json": 

471 print_json({"version": __version__}) 

472 raise typer.Exit(0) 

473 

474 

475def show_config_callback() -> Never: 

476 """ 

477 --show-config requested! 

478 """ 

479 match state.output_format: 

480 case "text": 

481 print(state) 

482 case "json": 

483 print_json(asdict(state)) 

484 raise typer.Exit(0) 

485 

486 

487@app.callback(invoke_without_command=True) 

488def main( 

489 ctx: typer.Context, 

490 config: str = None, 

491 verbosity: Verbosity = DEFAULT_VERBOSITY, 

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

493 # stops the program: 

494 show_config: bool = False, 

495 version: bool = False, 

496) -> None: 

497 """ 

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

499 

500 Args: 

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

502 config: path to a different config toml file 

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

504 output_format: output format 

505 

506 show_config: display current configuration? 

507 version: display current version? 

508 

509 """ 

510 if state.config: 

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

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

513 Singleton.clear(state.config) 

514 

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

516 

517 if show_config: 

518 show_config_callback() 

519 elif version: 

520 version_callback() 

521 elif not ctx.invoked_subcommand: 

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

523 # else: just continue 

524 

525 

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

527 app()