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

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

2 import typing

3 from json import load as json_load

4

5 import typer

6 from plumbum import local

7 from plumbum.commands.processes import CommandNotFound, ProcessExecutionError

8 from rich import print

9

10 from .core import (

11 DEFAULT_FORMAT,

12 DEFAULT_VERBOSITY,

13 EXIT_CODE_COMMAND_NOT_FOUND,

14 EXIT_CODE_ERROR,

15 EXIT_CODE_SUCCESS,

16 GREEN_CIRCLE,

17 RED_CIRCLE,

18 YELLOW_CIRCLE,

19 Format,

20 Verbosity,

21 dump_tools_with_results,

22 info,

23 log_cmd_output,

24 log_command,

25 state,

26 warn,

27 with_exit_code,

28)

29

30 app = typer.Typer()

31

32

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

34 """

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

36

37 Args:

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

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

40 """

41 try:

42 cmd = local[tool]

43

44 if state.verbosity >= 3:

45 log_command(cmd, args)

46

47 result = cmd(*args)

48

49 if state.format == "text":

50 print(GREEN_CIRCLE, tool)

51

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

53 log_cmd_output(result)

54

55 return EXIT_CODE_SUCCESS # success

56 except CommandNotFound: # pragma: no cover

57 if state.verbosity > 2:

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

59

60 if state.format == "text":

61 print(YELLOW_CIRCLE, tool)

62

63 return EXIT_CODE_COMMAND_NOT_FOUND # command not found

64 except ProcessExecutionError as e:

65 if state.format == "text":

66 print(RED_CIRCLE, tool)

67

68 if state.verbosity > 1:

69 log_cmd_output(e.stdout, e.stderr)

70 return EXIT_CODE_ERROR # general error

71

72

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

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

75

76

77@app.command()

78@with_exit_code()

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

80 """

81 Runs the Ruff Linter.

82

83 Args:

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

85

86 """

87 config = state.update_config(directory=directory)

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

89

90

91@app.command()

92@with_exit_code()

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

94 """

95 Runs the Black code formatter.

96

97 Args:

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

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

100

101 """

102 config = state.update_config(directory=directory)

103

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

105 if not fix:

106 args.append("--check")

107 elif state.verbosity > 2:

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

109

110 return _check_tool("black", *args)

111

112

113@app.command()

114@with_exit_code()

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

116 """

117 Runs the import sort (isort) utility.

118

119 Args:

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

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

122

123 """

124 config = state.update_config(directory=directory)

125 args = [config.directory]

126 if not fix:

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

128 elif state.verbosity > 2:

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

130

131 return _check_tool("isort", *args)

132

133

134@app.command()

135@with_exit_code()

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

137 """

138 Runs the mypy static type checker.

139

140 Args:

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

142

143 """

144 config = state.update_config(directory=directory)

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

146

147

148@app.command()

149@with_exit_code()

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

151 """

152 Runs the bandit security checker.

153

154 Args:

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

156

157 """

158 config = state.update_config(directory=directory)

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

160

161

162@app.command()

163@with_exit_code()

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

165 """

166 Runs the pydocstyle docstring checker.

167

168 Args:

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

170

171 """

172 config = state.update_config(directory=directory)

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

174

175

176@app.command()

177@with_exit_code()

178 def pytest(

179 directory: T_directory = None, html: bool = False, json: bool = False, coverage: int = None

180) -> int: # pragma: no cover

181 """

182 Runs all pytests.

183

184 Args:

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

186 html: generate HTML coverage output?

187 json: generate JSON coverage output?

188 coverage: threshold for coverage (in %)

189

190 Example:

191 > su6 pytest --coverage 50

192 if any checks fail: exit 1 and red circle

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

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

195

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

197 """

198 config = state.update_config(directory=directory, coverage=coverage)

199

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

201

202 if config.coverage:

203 # json output required!

204 json = True

205

206 if html:

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

208

209 if json:

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

211

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

213

214 if config.coverage:

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

216 data = json_load(f)

217 percent_covered = round(data["totals"]["percent_covered"])

218

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

220 exit_code = percent_covered < config.coverage

221 circle = RED_CIRCLE if exit_code else GREEN_CIRCLE

222 if state.format == "text":

223 print(circle, "coverage")

224

225 return exit_code

226

227

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

229@with_exit_code()

230 def check_all(directory: T_directory = None, ignore_uninstalled: bool = False, coverage: float = None) -> bool:

231 """

232 Run all available checks.

233

234 Args:

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

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

237 coverage: pass to pytest()

238

239 """

240 config = state.update_config(directory=directory)

241 ignored_exit_codes = set()

242 if ignore_uninstalled:

243 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND)

244

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

246

247 exit_codes = []

248 for tool in tools:

249 a = [directory]

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

251

252 if tool is pytest: # pragma: no cover

253 kw["coverage"] = coverage

254

255 exit_codes.append(tool(*a, **kw))

256

257 if state.format == "json":

258 dump_tools_with_results(tools, exit_codes)

259

260 return any(exit_codes)

261

262

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

264@with_exit_code()

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

266 """

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

268

269 Args:

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

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

272

273 """

274 config = state.update_config(directory=directory)

275

276 ignored_exit_codes = set()

277 if ignore_uninstalled:

278 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND)

279

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

281

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

283

284 if state.format == "json":

285 dump_tools_with_results(tools, exit_codes)

286

287 return any(exit_codes)

288

289

290@app.callback()

291 def main(config: str = None, verbosity: Verbosity = DEFAULT_VERBOSITY, format: Format = DEFAULT_FORMAT) -> None:

292 """

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

294

295 Args:

296 config: path to a different config toml file

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

298 format: output format

299

300 """

301 state.load_config(config_file=config, verbosity=verbosity, format=format)

302

303

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

305 app()