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

155 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-05 12:28 +0200

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

2import contextlib 

3import math 

4import os 

5import sys 

6import typing 

7from dataclasses import asdict 

8from importlib.metadata import entry_points 

9from json import load as json_load 

10 

11import typer 

12from plumbum import local 

13from plumbum.machines import LocalCommand 

14from rich import print 

15 

16from .__about__ import __version__ 

17from .core import ( 

18 DEFAULT_BADGE, 

19 DEFAULT_FORMAT, 

20 DEFAULT_VERBOSITY, 

21 EXIT_CODE_COMMAND_NOT_FOUND, 

22 GREEN_CIRCLE, 

23 RED_CIRCLE, 

24 Format, 

25 PlumbumError, 

26 Verbosity, 

27 dump_tools_with_results, 

28 info, 

29 log_command, 

30 print_json, 

31 run_tool, 

32 state, 

33 warn, 

34 with_exit_code, 

35) 

36from .plugins import include_plugins 

37 

38app = typer.Typer() 

39 

40include_plugins(app) 

41 

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

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

44 

45 

46@app.command() 

47@with_exit_code() 

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

49 """ 

50 Runs the Ruff Linter. 

51 

52 Args: 

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

54 

55 """ 

56 config = state.update_config(directory=directory) 

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

58 

59 

60@app.command() 

61@with_exit_code() 

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

63 """ 

64 Runs the Black code formatter. 

65 

66 Args: 

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

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

69 

70 """ 

71 config = state.update_config(directory=directory) 

72 

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

74 if not fix: 

75 args.append("--check") 

76 elif state.verbosity > 2: 

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

78 

79 return run_tool("black", *args) 

80 

81 

82@app.command() 

83@with_exit_code() 

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

85 """ 

86 Runs the import sort (isort) utility. 

87 

88 Args: 

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

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

91 

92 """ 

93 config = state.update_config(directory=directory) 

94 args = [config.directory] 

95 if not fix: 

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

97 elif state.verbosity > 2: 

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

99 

100 return run_tool("isort", *args) 

101 

102 

103@app.command() 

104@with_exit_code() 

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

106 """ 

107 Runs the mypy static type checker. 

108 

109 Args: 

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

111 

112 """ 

113 config = state.update_config(directory=directory) 

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

115 

116 

117@app.command() 

118@with_exit_code() 

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

120 """ 

121 Runs the bandit security checker. 

122 

123 Args: 

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

125 

126 """ 

127 config = state.update_config(directory=directory) 

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

129 

130 

131@app.command() 

132@with_exit_code() 

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

134 """ 

135 Runs the pydocstyle docstring checker. 

136 

137 Args: 

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

139 

140 """ 

141 config = state.update_config(directory=directory) 

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

143 

144 

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

146@with_exit_code() 

147def check_all( 

148 directory: T_directory = None, 

149 ignore_uninstalled: bool = False, 

150 stop_after_first_failure: bool = None, 

151 # pytest: 

152 coverage: float = None, 

153 badge: bool = None, 

154) -> bool: 

155 """ 

156 Run all available checks. 

157 

158 Args: 

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

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

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

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

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

164 

165 coverage: pass to pytest() 

166 badge: pass to pytest() 

167 

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

169 """ 

170 config = state.update_config( 

171 directory=directory, 

172 stop_after_first_failure=stop_after_first_failure, 

173 coverage=coverage, 

174 badge=badge, 

175 ) 

176 

177 ignored_exit_codes = set() 

178 if ignore_uninstalled: 

179 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND) 

180 

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

182 

183 exit_codes = [] 

184 for tool in tools: 

185 a = [directory] 

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

187 

188 if tool is pytest: # pragma: no cover 

189 kw["coverage"] = config.coverage 

190 kw["badge"] = config.badge 

191 

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

193 exit_codes.append(result) 

194 if config.stop_after_first_failure and result != 0: 

195 break 

196 

197 if state.output_format == "json": 

198 dump_tools_with_results(tools, exit_codes) 

199 

200 return any(exit_codes) 

201 

202 

203@app.command() 

204@with_exit_code() 

205def pytest( 

206 directory: T_directory = None, 

207 html: bool = False, 

208 json: bool = False, 

209 coverage: int = None, 

210 badge: bool = None, 

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

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

213) -> int: # pragma: no cover 

214 """ 

215 Runs all pytests. 

216 

217 Args: 

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

219 html: generate HTML coverage output? 

220 json: generate JSON coverage output? 

221 coverage: threshold for coverage (in %) 

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

223 

224 k: pytest -k <str> option 

225 s: pytest -s option 

226 

227 Example: 

228 > su6 pytest --coverage 50 

229 if any checks fail: exit 1 and red circle 

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

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

232 

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

234 """ 

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

236 

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

238 # not None but still check cov 

239 config.coverage = 0 

240 

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

242 

243 if config.coverage is not None: 

244 # json output required! 

245 json = True 

246 

247 if k: 

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

249 if s: 

250 args.append("-s") 

251 

252 if html: 

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

254 

255 if json: 

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

257 

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

259 

260 if config.coverage is not None: 

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

262 data = json_load(f) 

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

264 

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

266 exit_code = percent_covered < config.coverage 

267 circle = RED_CIRCLE if exit_code else GREEN_CIRCLE 

268 if state.output_format == "text": 

269 print(circle, "coverage") 

270 

271 if config.badge: 

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

273 # it's still True for some reason? 

274 config.badge = DEFAULT_BADGE 

275 

276 with contextlib.suppress(FileNotFoundError): 

277 os.remove(config.badge) 

278 

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

280 if state.verbosity > 2: 

281 info(result) 

282 

283 return exit_code 

284 

285 

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

287@with_exit_code() 

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

289 """ 

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

291 

292 Args: 

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

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

295 

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

297 """ 

298 config = state.update_config(directory=directory) 

299 

300 ignored_exit_codes = set() 

301 if ignore_uninstalled: 

302 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND) 

303 

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

305 

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

307 

308 if state.output_format == "json": 

309 dump_tools_with_results(tools, exit_codes) 

310 

311 return any(exit_codes) 

312 

313 

314@app.command() 

315@with_exit_code() 

316def plugins() -> None: 

317 """ 

318 List installed plugin modules. 

319 

320 """ 

321 modules = entry_points(group="su6") 

322 match state.output_format: 

323 case "text": 

324 if modules: 

325 print("Installed Plugins:") 

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

327 else: # pragma: nocover 

328 print("No Installed Plugins.") 

329 case "json": 

330 print_json( 

331 { 

332 _.name: { 

333 "name": _.name, 

334 "value": _.value, 

335 "group": _.group, 

336 } 

337 for _ in modules 

338 } 

339 ) 

340 

341 

342def _pip() -> LocalCommand: 

343 """ 

344 Return a `pip` command. 

345 """ 

346 python = sys.executable 

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

348 

349 

350@app.command() 

351@with_exit_code() 

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

353 """ 

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

355 

356 Args: 

357 version: (optional) specific version to update to 

358 """ 

359 pip = _pip() 

360 

361 try: 

362 pkg = "su6" 

363 if version: 

364 pkg = f"{pkg}=={version}" 

365 

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

367 if state.verbosity >= 3: 

368 log_command(pip, args) 

369 

370 output = pip(*args) 

371 if state.verbosity > 2: 

372 info(output) 

373 match state.output_format: 

374 case "text": 

375 print(GREEN_CIRCLE, "self-update") 

376 # case json handled automatically by with_exit_code 

377 return 0 

378 except PlumbumError as e: 

379 if state.verbosity > 3: 

380 raise e 

381 elif state.verbosity > 2: 

382 warn(str(e)) 

383 match state.output_format: 

384 case "text": 

385 print(RED_CIRCLE, "self-update") 

386 # case json handled automatically by with_exit_code 

387 return 1 

388 

389 

390def version_callback() -> typing.Never: 

391 """ 

392 --version requested! 

393 """ 

394 match state.output_format: 

395 case "text": 

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

397 case "json": 

398 print_json({"version": __version__}) 

399 raise typer.Exit(0) 

400 

401 

402def show_config_callback() -> typing.Never: 

403 """ 

404 --show-config requested! 

405 """ 

406 match state.output_format: 

407 case "text": 

408 print(state) 

409 case "json": 

410 print_json(asdict(state)) 

411 raise typer.Exit(0) 

412 

413 

414@app.callback(invoke_without_command=True) 

415def main( 

416 ctx: typer.Context, 

417 config: str = None, 

418 verbosity: Verbosity = DEFAULT_VERBOSITY, 

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

420 # stops the program: 

421 show_config: bool = False, 

422 version: bool = False, 

423) -> None: 

424 """ 

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

426 

427 Args: 

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

429 config: path to a different config toml file 

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

431 output_format: output format 

432 

433 show_config: display current configuration? 

434 version: display current version? 

435 

436 """ 

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

438 

439 if show_config: 

440 show_config_callback() 

441 elif version: 

442 version_callback() 

443 elif not ctx.invoked_subcommand: 

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

445 # else: just continue 

446 

447 

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

449 app()