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 return run_tool("mypy", config.directory)

117

118

119@app.command()

120@with_exit_code()

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

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

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

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

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

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

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

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

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

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

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

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

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

496 app()