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 json import load as json_load

9

10 import typer

11 from plumbum import local

12 from plumbum.commands.processes import CommandNotFound, ProcessExecutionError

13 from rich import print

14

15 from .__about__ import __version__

16 from .core import (

17 DEFAULT_BADGE,

18 DEFAULT_FORMAT,

19 DEFAULT_VERBOSITY,

20 EXIT_CODE_COMMAND_NOT_FOUND,

21 EXIT_CODE_ERROR,

22 EXIT_CODE_SUCCESS,

23 GREEN_CIRCLE,

24 RED_CIRCLE,

25 YELLOW_CIRCLE,

26 Format,

27 PlumbumError,

28 Verbosity,

29 dump_tools_with_results,

30 info,

31 log_cmd_output,

32 log_command,

33 print_json,

34 state,

35 warn,

36 with_exit_code,

37)

38

39 app = typer.Typer()

40

41

42 def _check_tool(tool: str, *args: str) -> int:

43 """

44 Abstraction to run one of the cli checking tools and process its output.

45

46 Args:

47 tool: the (bash) name of the tool to run.

48 args: cli args to pass to the cli bash tool

49 """

50 try:

51 cmd = local[tool]

52

53 if state.verbosity >= 3:

54 log_command(cmd, args)

55

56 result = cmd(*args)

57

58 if state.output_format == "text":

59 print(GREEN_CIRCLE, tool)

60

61 if state.verbosity > 2: # pragma: no cover

62 log_cmd_output(result)

63

64 return EXIT_CODE_SUCCESS # success

65 except CommandNotFound: # pragma: no cover

66 if state.verbosity > 2:

67 warn(f"Tool {tool} not installed!")

68

69 if state.output_format == "text":

70 print(YELLOW_CIRCLE, tool)

71

72 return EXIT_CODE_COMMAND_NOT_FOUND # command not found

73 except ProcessExecutionError as e:

74 if state.output_format == "text":

75 print(RED_CIRCLE, tool)

76

77 if state.verbosity > 1:

78 log_cmd_output(e.stdout, e.stderr)

79 return EXIT_CODE_ERROR # general error

80

81

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

83 T_directory: typing.TypeAlias = typing.Annotated[str, typer.Argument()] # = "."

84

85

86@app.command()

87@with_exit_code()

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

89 """

90 Runs the Ruff Linter.

91

92 Args:

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

94

95 """

96 config = state.update_config(directory=directory)

97 return _check_tool("ruff", config.directory)

98

99

100@app.command()

101@with_exit_code()

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

103 """

104 Runs the Black code formatter.

105

106 Args:

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

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

109

110 """

111 config = state.update_config(directory=directory)

112

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

114 if not fix:

115 args.append("--check")

116 elif state.verbosity > 2:

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

118

119 return _check_tool("black", *args)

120

121

122@app.command()

123@with_exit_code()

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

125 """

126 Runs the import sort (isort) utility.

127

128 Args:

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

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

131

132 """

133 config = state.update_config(directory=directory)

134 args = [config.directory]

135 if not fix:

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

137 elif state.verbosity > 2:

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

139

140 return _check_tool("isort", *args)

141

142

143@app.command()

144@with_exit_code()

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

146 """

147 Runs the mypy static type checker.

148

149 Args:

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

151

152 """

153 config = state.update_config(directory=directory)

154 return _check_tool("mypy", config.directory)

155

156

157@app.command()

158@with_exit_code()

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

160 """

161 Runs the bandit security checker.

162

163 Args:

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

165

166 """

167 config = state.update_config(directory=directory)

168 return _check_tool("bandit", "-r", "-c", config.pyproject, config.directory)

169

170

171@app.command()

172@with_exit_code()

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

174 """

175 Runs the pydocstyle docstring checker.

176

177 Args:

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

179

180 """

181 config = state.update_config(directory=directory)

182 return _check_tool("pydocstyle", config.directory)

183

184

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

186@with_exit_code()

187 def check_all(

188 directory: T_directory = None,

189 ignore_uninstalled: bool = False,

190 stop_after_first_failure: bool = None,

191 # pytest:

192 coverage: float = None,

193 badge: bool = None,

194) -> bool:

195 """

196 Run all available checks.

197

198 Args:

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

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

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

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

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

204

205 coverage: pass to pytest()

206 badge: pass to pytest()

207

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

209 """

210 config = state.update_config(

211 directory=directory,

212 stop_after_first_failure=stop_after_first_failure,

213 coverage=coverage,

214 badge=badge,

215 )

216

217 ignored_exit_codes = set()

218 if ignore_uninstalled:

219 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND)

220

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

222

223 exit_codes = []

224 for tool in tools:

225 a = [directory]

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

227

228 if tool is pytest: # pragma: no cover

229 kw["coverage"] = config.coverage

230 kw["badge"] = config.badge

231

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

233 exit_codes.append(result)

234 if config.stop_after_first_failure and result != 0:

235 break

236

237 if state.output_format == "json":

238 dump_tools_with_results(tools, exit_codes)

239

240 return any(exit_codes)

241

242

243@app.command()

244@with_exit_code()

245 def pytest(

246 directory: T_directory = None,

247 html: bool = False,

248 json: bool = False,

249 coverage: int = None,

250 badge: bool = None,

251) -> int: # pragma: no cover

252 """

253 Runs all pytests.

254

255 Args:

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

257 html: generate HTML coverage output?

258 json: generate JSON coverage output?

259 coverage: threshold for coverage (in %)

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

261

262 Example:

263 > su6 pytest --coverage 50

264 if any checks fail: exit 1 and red circle

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

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

267

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

269 """

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

271

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

273 # not None but still check cov

274 config.coverage = 0

275

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

277

278 if config.coverage is not None:

279 # json output required!

280 json = True

281

282 if html:

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

284

285 if json:

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

287

288 exit_code = _check_tool("pytest", *args)

289

290 if config.coverage is not None:

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

292 data = json_load(f)

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

294

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

296 exit_code = percent_covered < config.coverage

297 circle = RED_CIRCLE if exit_code else GREEN_CIRCLE

298 if state.output_format == "text":

299 print(circle, "coverage")

300

301 if config.badge:

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

303 # it's still True for some reason?

304 config.badge = DEFAULT_BADGE

305

306 with contextlib.suppress(FileNotFoundError):

307 os.remove(config.badge)

308

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

310 if state.verbosity > 2:

311 info(result)

312

313 return exit_code

314

315

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

317@with_exit_code()

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

319 """

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

321

322 Args:

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

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

325

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

327 """

328 config = state.update_config(directory=directory)

329

330 ignored_exit_codes = set()

331 if ignore_uninstalled:

332 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND)

333

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

335

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

337

338 if state.output_format == "json":

339 dump_tools_with_results(tools, exit_codes)

340

341 return any(exit_codes)

342

343

344@app.command()

345@with_exit_code()

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

347 """

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

349

350 Args:

351 version: (optional) specific version to update to

352 """

353 python = sys.executable

354 pip = local[python]["-m", "pip"]

355

356 try:

357 pkg = "su6"

358 if version:

359 pkg = f"{pkg}=={version}"

360

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

362 if state.verbosity >= 3:

363 log_command(pip, args)

364

365 output = pip(*args)

366 if state.verbosity > 2:

367 info(output)

368 match state.output_format:

369 case "text":

370 print(GREEN_CIRCLE, "self-update")

371 # case json handled automatically by with_exit_code

372 return 0

373 except PlumbumError as e:

374 if state.verbosity > 3:

375 raise e

376 elif state.verbosity > 2:

377 warn(str(e))

378 match state.output_format:

379 case "text":

380 print(RED_CIRCLE, "self-update")

381 # case json handled automatically by with_exit_code

382 return 1

383

384

385 def version_callback() -> typing.Never:

386 """

387 --version requested!

388 """

389 match state.output_format:

390 case "text":

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

392 case "json":

393 print_json({"version": __version__})

394 raise typer.Exit(0)

395

396

397 def show_config_callback() -> typing.Never:

398 """

399 --show-config requested!

400 """

401 match state.output_format:

402 case "text":

403 print(state)

404 case "json":

405 print_json(asdict(state))

406 raise typer.Exit(0)

407

408

409@app.callback(invoke_without_command=True)

410 def main(

411 ctx: typer.Context,

412 config: str = None,

413 verbosity: Verbosity = DEFAULT_VERBOSITY,

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

415 # stops the program:

416 show_config: bool = False,

417 version: bool = False,

418) -> None:

419 """

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

421

422 Args:

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

424 config: path to a different config toml file

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

426 output_format: output format

427

428 show_config: display current configuration?

429 version: display current version?

430

431 """

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

433

434 if show_config:

435 show_config_callback()

436 elif version:

437 version_callback()

438 elif not ctx.invoked_subcommand:

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

440 # else: just continue

441

442

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

444 app()