Coverage for src/su6/cli.py: 100%
94 statements
« prev ^ index » next coverage.py v7.2.6, created at 2023-05-30 12:33 +0200
« prev ^ index » next coverage.py v7.2.6, created at 2023-05-30 12:33 +0200
1"""This file contains all Typer Commands."""
2import typing
3from json import load as json_load
5import typer
6from plumbum import local
7from plumbum.commands.processes import CommandNotFound, ProcessExecutionError
8from rich import print
10from .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)
27app = typer.Typer()
30def _check_tool(tool: str, *args: str) -> int:
31 """
32 Abstraction to run one of the cli checking tools and process its output.
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]
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
63# 'directory' is an optional cli argument to many commands, so we define the type here for reuse:
64T_directory: typing.TypeAlias = typing.Annotated[str, typer.Argument()] # = "."
67@app.command()
68@with_exit_code()
69def ruff(directory: T_directory = None) -> int:
70 """
71 Runs the Ruff Linter.
73 Args:
74 directory: where to run ruff on (default is current dir)
76 """
77 config = state.update_config(directory=directory)
78 return _check_tool("ruff", config.directory)
81@app.command()
82@with_exit_code()
83def black(directory: T_directory = None, fix: bool = False) -> int:
84 """
85 Runs the Black code formatter.
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).
91 """
92 config = state.update_config(directory=directory)
94 args = [config.directory, r"--exclude=venv.+|.+\.bak"]
95 if not fix:
96 args.append("--check")
97 elif state.verbosity > 2:
98 info("note: running WITHOUT --check -> changing files")
100 return _check_tool("black", *args)
103@app.command()
104@with_exit_code()
105def isort(directory: T_directory = None, fix: bool = False) -> int:
106 """
107 Runs the import sort (isort) utility.
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.
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")
121 return _check_tool("isort", *args)
124@app.command()
125@with_exit_code()
126def mypy(directory: T_directory = None) -> int:
127 """
128 Runs the mypy static type checker.
130 Args:
131 directory: where to run mypy on (default is current dir)
133 """
134 config = state.update_config(directory=directory)
135 return _check_tool("mypy", config.directory)
138@app.command()
139@with_exit_code()
140def bandit(directory: T_directory = None) -> int:
141 """
142 Runs the bandit security checker.
144 Args:
145 directory: where to run bandit on (default is current dir)
147 """
148 config = state.update_config(directory=directory)
149 return _check_tool("bandit", "-r", "-c", config.pyproject, config.directory)
152@app.command()
153@with_exit_code()
154def pydocstyle(directory: T_directory = None) -> int:
155 """
156 Runs the pydocstyle docstring checker.
158 Args:
159 directory: where to run pydocstyle on (default is current dir)
161 """
162 config = state.update_config(directory=directory)
163 return _check_tool("pydocstyle", config.directory)
166@app.command()
167@with_exit_code()
168def pytest(
169 directory: T_directory = None, html: bool = False, json: bool = False, coverage: int = None
170) -> int: # pragma: no cover
171 """
172 Runs all pytests.
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 %)
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
186 if --coverage is not passed, there will be no circle for coverage.
187 """
188 config = state.update_config(directory=directory, coverage=coverage)
190 args = ["--cov", config.directory]
192 if config.coverage:
193 # json output required!
194 json = True
196 if html:
197 args.extend(["--cov-report", "html"])
199 if json:
200 args.extend(["--cov-report", "json"])
202 exit_code = _check_tool("pytest", *args)
204 if config.coverage:
205 with open("coverage.json") as f:
206 data = json_load(f)
207 percent_covered = round(data["totals"]["percent_covered"])
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")
214 return exit_code
217@app.command(name="all")
218@with_exit_code()
219def check_all(directory: T_directory = None, ignore_uninstalled: bool = False, coverage: float = None) -> bool:
220 """
221 Run all available checks.
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()
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)
234 tools = config.determine_which_to_run([ruff, black, mypy, bandit, isort, pydocstyle, pytest])
236 exit_codes = []
237 for tool in tools:
238 a = [directory]
239 kw = dict(_suppress=True, _ignore=ignored_exit_codes)
241 if tool is pytest: # pragma: no cover
242 kw["coverage"] = coverage
244 exit_codes.append(tool(*a, **kw))
246 return any(exit_codes)
249@app.command(name="fix")
250@with_exit_code()
251def 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).
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)
259 """
260 config = state.update_config(directory=directory)
262 ignored_exit_codes = set()
263 if ignore_uninstalled:
264 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND)
266 tools = config.determine_which_to_run([black, isort])
268 exit_codes = [tool(directory, fix=True, _suppress=True, _ignore=ignored_exit_codes) for tool in tools]
270 return any(exit_codes)
273@app.callback()
274def main(config: str = None, verbosity: Verbosity = DEFAULT_VERBOSITY) -> None:
275 """
276 This callback will run before every command, setting the right global flags.
278 Args:
279 config: path to a different config toml file
280 verbosity: level of detail to print out (1 - 3)
282 Todo:
283 - add --format option for json
284 """
285 state.load_config(config_file=config, verbosity=verbosity)
288if __name__ == "__main__": # pragma: no cover
289 app()