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

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_VERBOSITY,

12 EXIT_CODE_COMMAND_NOT_FOUND,

13 EXIT_CODE_ERROR,

14 EXIT_CODE_SUCCESS,

15 GREEN_CIRCLE,

16 RED_CIRCLE,

17 YELLOW_CIRCLE,

18 Verbosity,

19 info,

20 log_cmd_output,

21 log_command,

22 state,

23 warn,

24 with_exit_code,

25)

26

27 app = typer.Typer()

28

29

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

31 """

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

33

34 Args:

35 tool: the (bash) name of the tool to run

36 . \

37 Level 1 (quiet) will only print a colored circle indicating success/failure; \

38 Level 2 (normal) will also print stdout/stderr; \

39 Level 3 (verbose) will also print the executed command with its arguments.

40 """

41 try:

42 cmd = local[tool]

43

44 if state.verbosity >= 3:

45 log_command(cmd, args)

46 result = cmd(*args)

47 print(GREEN_CIRCLE, tool)

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

49 log_cmd_output(result)

50 return EXIT_CODE_SUCCESS # success

51 except CommandNotFound:

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

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

54 print(YELLOW_CIRCLE, tool)

55 return EXIT_CODE_COMMAND_NOT_FOUND # command not found

56 except ProcessExecutionError as e:

57 print(RED_CIRCLE, tool)

58 if state.verbosity > 1:

59 log_cmd_output(e.stdout, e.stderr)

60 return EXIT_CODE_ERROR # general error

61

62

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

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

65

66

67@app.command()

68@with_exit_code()

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

70 """

71 Runs the Ruff Linter.

72

73 Args:

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

75

76 """

77 config = state.update_config(directory=directory)

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

79

80

81@app.command()

82@with_exit_code()

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

84 """

85 Runs the Black code formatter.

86

87 Args:

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

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

90

91 """

92 config = state.update_config(directory=directory)

93

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

95 if not fix:

96 args.append("--check")

97 elif state.verbosity > 2:

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

99

100 return _check_tool("black", *args)

101

102

103@app.command()

104@with_exit_code()

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

106 """

107 Runs the import sort (isort) utility.

108

109 Args:

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

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

112

113 """

114 config = state.update_config(directory=directory)

115 args = [config.directory]

116 if not fix:

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

118 elif state.verbosity > 2:

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

120

121 return _check_tool("isort", *args)

122

123

124@app.command()

125@with_exit_code()

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

127 """

128 Runs the mypy static type checker.

129

130 Args:

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

132

133 """

134 config = state.update_config(directory=directory)

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

136

137

138@app.command()

139@with_exit_code()

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

141 """

142 Runs the bandit security checker.

143

144 Args:

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

146

147 """

148 config = state.update_config(directory=directory)

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

150

151

152@app.command()

153@with_exit_code()

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

155 """

156 Runs the pydocstyle docstring checker.

157

158 Args:

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

160

161 """

162 config = state.update_config(directory=directory)

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

164

165

166@app.command()

167@with_exit_code()

168 def pytest(

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

170) -> int:

171 """

172 Runs all pytests.

173

174 Args:

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

176 html: generate HTML coverage output?

177 json: generate JSON coverage output?

178 coverage: threshold for coverage (in %)

179

180 Example:

181 > su6 pytest --coverage 50

182 if any checks fail: exit 1 and red circle

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

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

185

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

187 """

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

189

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

191

192 if config.coverage:

193 # json output required!

194 json = True

195

196 if html:

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

198

199 if json:

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

201

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

203

204 if config.coverage:

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

206 data = json_load(f)

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

208

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

210 exit_code = percent_covered < config.coverage

211 circle = RED_CIRCLE if exit_code else GREEN_CIRCLE

212 print(circle, "coverage")

213

214 return exit_code

215

216

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

218@with_exit_code()

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

220 """

221 Run all available checks.

222

223 Args:

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

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

226 coverage: pass to pytest()

227

228 """

229 config = state.update_config(directory=directory)

230 ignored_exit_codes = set()

231 if ignore_uninstalled:

232 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND)

233

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

235

236 exit_codes = []

237 for tool in tools:

238 a = [directory]

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

240

241 if tool is pytest: # pragma: no cover

242 kw["coverage"] = coverage

243

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

245

246 return any(exit_codes)

247

248

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

250@with_exit_code()

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

252 """

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

254

255 Args:

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

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

258

259 """

260 config = state.update_config(directory=directory)

261

262 ignored_exit_codes = set()

263 if ignore_uninstalled:

264 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND)

265

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

267

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

269

270 return any(exit_codes)

271

272

273@app.callback()

274 def main(config: str = None, verbosity: Verbosity = DEFAULT_VERBOSITY) -> None:

275 """

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

277

278 Args:

279 config: path to a different config toml file

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

281

282 Todo:

283 - add --format option for json

284 """

285 state.load_config(config_file=config, verbosity=verbosity)

286

287

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

289 app()