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

38 from .plugins import include_plugins

39

40 app = typer.Typer()

41

42 include_plugins(app)

43

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

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

46

47

48@app.command()

49@with_exit_code()

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

501 app()