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

157 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-08 13:53 +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 Singleton, 

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) 

37from .plugins import include_plugins 

38 

39app = typer.Typer() 

40 

41include_plugins(app) 

42 

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

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

45 

46 

47@app.command() 

48@with_exit_code() 

49def 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() 

63def 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() 

85def 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() 

106def 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() 

120def 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() 

134def 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() 

148def 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() 

206def 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() 

297def 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() 

325def plugins() -> None: 

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: # pragma: nocover 

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 

351def _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() 

361def 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 

399def 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 

411def 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) 

424def 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 

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

463 app()