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

17 from .__about__ import __version__

18 from .core import (

19 DEFAULT_BADGE,

20 DEFAULT_FORMAT,

21 DEFAULT_VERBOSITY,

22 EXIT_CODE_COMMAND_NOT_FOUND,

23 GREEN_CIRCLE,

24 RED_CIRCLE,

25 Format,

26 PlumbumError,

27 Verbosity,

28 dump_tools_with_results,

29 info,

30 log_command,

31 print_json,

32 run_tool,

33 state,

34 warn,

35 with_exit_code,

36)

37 from .plugins import include_plugins

38

39 app = typer.Typer()

40

41 include_plugins(app)

42

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

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

45

46

47@app.command()

48@with_exit_code()

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

50 """

51 Runs the Ruff Linter.

52

53 Args:

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

55

56 """

57 config = state.update_config(directory=directory)

58 return run_tool("ruff", config.directory)

59

60

61@app.command()

62@with_exit_code()

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

64 """

65 Runs the Black code formatter.

66

67 Args:

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

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

70

71 """

72 config = state.update_config(directory=directory)

73

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

75 if not fix:

76 args.append("--check")

77 elif state.verbosity > 2:

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

79

80 return run_tool("black", *args)

81

82

83@app.command()

84@with_exit_code()

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

86 """

87 Runs the import sort (isort) utility.

88

89 Args:

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

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

92

93 """

94 config = state.update_config(directory=directory)

95 args = [config.directory]

96 if not fix:

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

98 elif state.verbosity > 2:

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

100

101 return run_tool("isort", *args)

102

103

104@app.command()

105@with_exit_code()

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

107 """

108 Runs the mypy static type checker.

109

110 Args:

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

112

113 """

114 config = state.update_config(directory=directory)

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

116

117

118@app.command()

119@with_exit_code()

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

121 """

122 Runs the bandit security checker.

123

124 Args:

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

126

127 """

128 config = state.update_config(directory=directory)

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

130

131

132@app.command()

133@with_exit_code()

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

135 """

136 Runs the pydocstyle docstring checker.

137

138 Args:

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

140

141 """

142 config = state.update_config(directory=directory)

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

144

145

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

147@with_exit_code()

148 def check_all(

149 directory: T_directory = None,

150 ignore_uninstalled: bool = False,

151 stop_after_first_failure: bool = None,

152 # pytest:

153 coverage: float = None,

154 badge: bool = None,

155) -> bool:

156 """

157 Run all available checks.

158

159 Args:

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

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

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

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

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

165

166 coverage: pass to pytest()

167 badge: pass to pytest()

168

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

170 """

171 config = state.update_config(

172 directory=directory,

173 stop_after_first_failure=stop_after_first_failure,

174 coverage=coverage,

175 badge=badge,

176 )

177

178 ignored_exit_codes = set()

179 if ignore_uninstalled:

180 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND)

181

182 tools = config.determine_which_to_run([ruff, black, mypy, bandit, isort, pydocstyle, pytest])

183

184 exit_codes = []

185 for tool in tools:

186 a = [directory]

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

188

189 if tool is pytest: # pragma: no cover

190 kw["coverage"] = config.coverage

191 kw["badge"] = config.badge

192

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

194 exit_codes.append(result)

195 if config.stop_after_first_failure and result != 0:

196 break

197

198 if state.output_format == "json":

199 dump_tools_with_results(tools, exit_codes)

200

201 return any(exit_codes)

202

203

204@app.command()

205@with_exit_code()

206 def pytest(

207 directory: T_directory = None,

208 html: bool = False,

209 json: bool = False,

210 coverage: int = None,

211 badge: bool = None,

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

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

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

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

216) -> int: # pragma: no cover

217 """

218 Runs all pytests.

219

220 Args:

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

222 html: generate HTML coverage output?

223 json: generate JSON coverage output?

224 coverage: threshold for coverage (in %)

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

226

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

228 s: pytest -s option (show output)

229 v: pytest -v option (verbose)

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

231

232 Example:

233 > su6 pytest --coverage 50

234 if any checks fail: exit 1 and red circle

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

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

237

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

239 """

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

241

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

243 # not None but still check cov

244 config.coverage = 0

245

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

247

248 if config.coverage is not None:

249 # json output required!

250 json = True

251

252 if k:

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

254 if s:

255 args.append("-s")

256 if v:

257 args.append("-v")

258 if x:

259 args.append("-x")

260

261 if html:

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

263

264 if json:

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

266

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

268

269 if config.coverage is not None:

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

271 data = json_load(f)

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

273

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

275 exit_code = percent_covered < config.coverage

276 circle = RED_CIRCLE if exit_code else GREEN_CIRCLE

277 if state.output_format == "text":

278 print(circle, "coverage")

279

280 if config.badge:

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

282 # it's still True for some reason?

283 config.badge = DEFAULT_BADGE

284

285 with contextlib.suppress(FileNotFoundError):

286 os.remove(config.badge)

287

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

289 if state.verbosity > 2:

290 info(result)

291

292 return exit_code

293

294

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

296@with_exit_code()

297 def do_fix(directory: T_directory = None, ignore_uninstalled: bool = False) -> bool:

298 """

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

300

301 Args:

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

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

304

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

306 """

307 config = state.update_config(directory=directory)

308

309 ignored_exit_codes = set()

310 if ignore_uninstalled:

311 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND)

312

313 tools = config.determine_which_to_run([black, isort])

314

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

316

317 if state.output_format == "json":

318 dump_tools_with_results(tools, exit_codes)

319

320 return any(exit_codes)

321

322

323@app.command()

324@with_exit_code()

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

326 """

327 List installed plugin modules.

328

329 """

330 modules = entry_points(group="su6")

331 match state.output_format:

332 case "text":

333 if modules:

334 print("Installed Plugins:")

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

336 else:

337 print("No Installed Plugins.")

338 case "json":

339 print_json(

340 {

341 _.name: {

342 "name": _.name,

343 "value": _.value,

344 "group": _.group,

345 }

346 for _ in modules

347 }

348 )

349

350

351 def _pip() -> LocalCommand:

352 """

353 Return a `pip` command.

354 """

355 python = sys.executable

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

357

358

359@app.command()

360@with_exit_code()

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

362 """

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

364

365 Args:

366 version: (optional) specific version to update to

367 """

368 pip = _pip()

369

370 try:

371 pkg = "su6"

372 if version:

373 pkg = f"{pkg}=={version}"

374

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

376 if state.verbosity >= 3:

377 log_command(pip, args)

378

379 output = pip(*args)

380 if state.verbosity > 2:

381 info(output)

382 match state.output_format:

383 case "text":

384 print(GREEN_CIRCLE, "self-update")

385 # case json handled automatically by with_exit_code

386 return 0

387 except PlumbumError as e:

388 if state.verbosity > 3:

389 raise e

390 elif state.verbosity > 2:

391 warn(str(e))

392 match state.output_format:

393 case "text":

394 print(RED_CIRCLE, "self-update")

395 # case json handled automatically by with_exit_code

396 return 1

397

398

399 def version_callback() -> typing.Never:

400 """

401 --version requested!

402 """

403 match state.output_format:

404 case "text":

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

406 case "json":

407 print_json({"version": __version__})

408 raise typer.Exit(0)

409

410

411 def show_config_callback() -> typing.Never:

412 """

413 --show-config requested!

414 """

415 match state.output_format:

416 case "text":

417 print(state)

418 case "json":

419 print_json(asdict(state))

420 raise typer.Exit(0)

421

422

423@app.callback(invoke_without_command=True)

424 def main(

425 ctx: typer.Context,

426 config: str = None,

427 verbosity: Verbosity = DEFAULT_VERBOSITY,

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

429 # stops the program:

430 show_config: bool = False,

431 version: bool = False,

432) -> None:

433 """

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

435

436 Args:

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

438 config: path to a different config toml file

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

440 output_format: output format

441

442 show_config: display current configuration?

443 version: display current version?

444

445 """

446 if state.config:

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

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

449 Singleton.clear(state.config)

450

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

452

453 if show_config:

454 show_config_callback()

455 elif version:

456 version_callback()

457 elif not ctx.invoked_subcommand:

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

459 # else: just continue

460

461

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

463 app()