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

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

2 import contextlib

3 import math

4 import os

5 import sys

6 import typing

7 from dataclasses import asdict

8 from importlib.metadata import entry_points

9 from json import load as json_load

10

11 import typer

12 from configuraptor import Singleton

13 from plumbum import local

14 from plumbum.machines import LocalCommand

15 from rich import print

16 from typing_extensions import Never

17

18 from .__about__ import __version__

19 from .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)

40 from .plugins import include_plugins

41

42 app = typer.Typer()

43

44 include_plugins(app)

45

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

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

48

49

50@app.command()

51@with_exit_code()

52 def 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()

66 def 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()

88 def 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()

109 def 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()

124 def 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()

138 def 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()

158 def 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()

189 def 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()

251 def 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()

342 def 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()

374 def 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

400 def _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()

410 def 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

448 def 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

460 def 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)

473 def 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

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

512 app()