Coverage for src/su6/cli.py: 100%
154 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-01 14:32 +0200
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-01 14:32 +0200
1"""This file contains all Typer Commands."""
2import contextlib
3import math
4import os
5import sys
6import typing
7from dataclasses import asdict
8from json import load as json_load
10import typer
11from plumbum import local
12from plumbum.commands.processes import CommandNotFound, ProcessExecutionError
13from rich import print
15from .__about__ import __version__
16from .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)
39app = typer.Typer()
42def _check_tool(tool: str, *args: str) -> int:
43 """
44 Abstraction to run one of the cli checking tools and process its output.
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]
53 if state.verbosity >= 3:
54 log_command(cmd, args)
56 result = cmd(*args)
58 if state.output_format == "text":
59 print(GREEN_CIRCLE, tool)
61 if state.verbosity > 2: # pragma: no cover
62 log_cmd_output(result)
64 return EXIT_CODE_SUCCESS # success
65 except CommandNotFound: # pragma: no cover
66 if state.verbosity > 2:
67 warn(f"Tool {tool} not installed!")
69 if state.output_format == "text":
70 print(YELLOW_CIRCLE, tool)
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)
77 if state.verbosity > 1:
78 log_cmd_output(e.stdout, e.stderr)
79 return EXIT_CODE_ERROR # general error
82# 'directory' is an optional cli argument to many commands, so we define the type here for reuse:
83T_directory: typing.TypeAlias = typing.Annotated[str, typer.Argument()] # = "."
86@app.command()
87@with_exit_code()
88def ruff(directory: T_directory = None) -> int:
89 """
90 Runs the Ruff Linter.
92 Args:
93 directory: where to run ruff on (default is current dir)
95 """
96 config = state.update_config(directory=directory)
97 return _check_tool("ruff", config.directory)
100@app.command()
101@with_exit_code()
102def black(directory: T_directory = None, fix: bool = False) -> int:
103 """
104 Runs the Black code formatter.
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).
110 """
111 config = state.update_config(directory=directory)
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")
119 return _check_tool("black", *args)
122@app.command()
123@with_exit_code()
124def isort(directory: T_directory = None, fix: bool = False) -> int:
125 """
126 Runs the import sort (isort) utility.
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.
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")
140 return _check_tool("isort", *args)
143@app.command()
144@with_exit_code()
145def mypy(directory: T_directory = None) -> int:
146 """
147 Runs the mypy static type checker.
149 Args:
150 directory: where to run mypy on (default is current dir)
152 """
153 config = state.update_config(directory=directory)
154 return _check_tool("mypy", config.directory)
157@app.command()
158@with_exit_code()
159def bandit(directory: T_directory = None) -> int:
160 """
161 Runs the bandit security checker.
163 Args:
164 directory: where to run bandit on (default is current dir)
166 """
167 config = state.update_config(directory=directory)
168 return _check_tool("bandit", "-r", "-c", config.pyproject, config.directory)
171@app.command()
172@with_exit_code()
173def pydocstyle(directory: T_directory = None) -> int:
174 """
175 Runs the pydocstyle docstring checker.
177 Args:
178 directory: where to run pydocstyle on (default is current dir)
180 """
181 config = state.update_config(directory=directory)
182 return _check_tool("pydocstyle", config.directory)
185@app.command(name="all")
186@with_exit_code()
187def 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.
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.
205 coverage: pass to pytest()
206 badge: pass to pytest()
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 )
217 ignored_exit_codes = set()
218 if ignore_uninstalled:
219 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND)
221 tools = config.determine_which_to_run([ruff, black, mypy, bandit, isort, pydocstyle, pytest])
223 exit_codes = []
224 for tool in tools:
225 a = [directory]
226 kw = dict(_suppress=True, _ignore=ignored_exit_codes)
228 if tool is pytest: # pragma: no cover
229 kw["coverage"] = config.coverage
230 kw["badge"] = config.badge
232 result = tool(*a, **kw)
233 exit_codes.append(result)
234 if config.stop_after_first_failure and result != 0:
235 break
237 if state.output_format == "json":
238 dump_tools_with_results(tools, exit_codes)
240 return any(exit_codes)
243@app.command()
244@with_exit_code()
245def 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.
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
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
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)
272 if config.badge and config.coverage is None:
273 # not None but still check cov
274 config.coverage = 0
276 args = ["--cov", config.directory]
278 if config.coverage is not None:
279 # json output required!
280 json = True
282 if html:
283 args.extend(["--cov-report", "html"])
285 if json:
286 args.extend(["--cov-report", "json"])
288 exit_code = _check_tool("pytest", *args)
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"])
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")
301 if config.badge:
302 if not isinstance(config.badge, str):
303 # it's still True for some reason?
304 config.badge = DEFAULT_BADGE
306 with contextlib.suppress(FileNotFoundError):
307 os.remove(config.badge)
309 result = local["coverage-badge"]("-o", config.badge)
310 if state.verbosity > 2:
311 info(result)
313 return exit_code
316@app.command(name="fix")
317@with_exit_code()
318def 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).
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)
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)
330 ignored_exit_codes = set()
331 if ignore_uninstalled:
332 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND)
334 tools = config.determine_which_to_run([black, isort])
336 exit_codes = [tool(directory, fix=True, _suppress=True, _ignore=ignored_exit_codes) for tool in tools]
338 if state.output_format == "json":
339 dump_tools_with_results(tools, exit_codes)
341 return any(exit_codes)
344@app.command()
345@with_exit_code()
346def self_update(version: str = None) -> int:
347 """
348 Update `su6` to the latest (stable) version.
350 Args:
351 version: (optional) specific version to update to
352 """
353 python = sys.executable
354 pip = local[python]["-m", "pip"]
356 try:
357 pkg = "su6"
358 if version:
359 pkg = f"{pkg}=={version}"
361 args = ["install", "--upgrade", pkg]
362 if state.verbosity >= 3:
363 log_command(pip, args)
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
385def 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)
397def 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)
409@app.callback(invoke_without_command=True)
410def 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.
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
428 show_config: display current configuration?
429 version: display current version?
431 """
432 state.load_config(config_file=config, verbosity=verbosity, output_format=output_format)
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
443if __name__ == "__main__": # pragma: no cover
444 app()