Coverage for src/su6/cli.py: 100%
97 statements
« prev ^ index » next coverage.py v7.2.6, created at 2023-05-30 13:45 +0200
« prev ^ index » next coverage.py v7.2.6, created at 2023-05-30 13:45 +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_FORMAT,
12 DEFAULT_VERBOSITY,
13 EXIT_CODE_COMMAND_NOT_FOUND,
14 EXIT_CODE_ERROR,
15 EXIT_CODE_SUCCESS,
16 GREEN_CIRCLE,
17 RED_CIRCLE,
18 YELLOW_CIRCLE,
19 Format,
20 Verbosity,
21 dump_tools_with_results,
22 info,
23 log_cmd_output,
24 log_command,
25 state,
26 warn,
27 with_exit_code,
28)
30app = typer.Typer()
33def _check_tool(tool: str, *args: str) -> int:
34 """
35 Abstraction to run one of the cli checking tools and process its output.
37 Args:
38 tool: the (bash) name of the tool to run.
39 args: cli args to pass to the cli bash tool
40 """
41 try:
42 cmd = local[tool]
44 if state.verbosity >= 3:
45 log_command(cmd, args)
47 result = cmd(*args)
49 if state.format == "text":
50 print(GREEN_CIRCLE, tool)
52 if state.verbosity > 2: # pragma: no cover
53 log_cmd_output(result)
55 return EXIT_CODE_SUCCESS # success
56 except CommandNotFound: # pragma: no cover
57 if state.verbosity > 2:
58 warn(f"Tool {tool} not installed!")
60 if state.format == "text":
61 print(YELLOW_CIRCLE, tool)
63 return EXIT_CODE_COMMAND_NOT_FOUND # command not found
64 except ProcessExecutionError as e:
65 if state.format == "text":
66 print(RED_CIRCLE, tool)
68 if state.verbosity > 1:
69 log_cmd_output(e.stdout, e.stderr)
70 return EXIT_CODE_ERROR # general error
73# 'directory' is an optional cli argument to many commands, so we define the type here for reuse:
74T_directory: typing.TypeAlias = typing.Annotated[str, typer.Argument()] # = "."
77@app.command()
78@with_exit_code()
79def ruff(directory: T_directory = None) -> int:
80 """
81 Runs the Ruff Linter.
83 Args:
84 directory: where to run ruff on (default is current dir)
86 """
87 config = state.update_config(directory=directory)
88 return _check_tool("ruff", config.directory)
91@app.command()
92@with_exit_code()
93def black(directory: T_directory = None, fix: bool = False) -> int:
94 """
95 Runs the Black code formatter.
97 Args:
98 directory: where to run black on (default is current dir)
99 fix: if --fix is passed, black will be used to reformat the file(s).
101 """
102 config = state.update_config(directory=directory)
104 args = [config.directory, r"--exclude=venv.+|.+\.bak"]
105 if not fix:
106 args.append("--check")
107 elif state.verbosity > 2:
108 info("note: running WITHOUT --check -> changing files")
110 return _check_tool("black", *args)
113@app.command()
114@with_exit_code()
115def isort(directory: T_directory = None, fix: bool = False) -> int:
116 """
117 Runs the import sort (isort) utility.
119 Args:
120 directory: where to run isort on (default is current dir)
121 fix: if --fix is passed, isort will be used to rearrange imports.
123 """
124 config = state.update_config(directory=directory)
125 args = [config.directory]
126 if not fix:
127 args.append("--check-only")
128 elif state.verbosity > 2:
129 info("note: running WITHOUT --check -> changing files")
131 return _check_tool("isort", *args)
134@app.command()
135@with_exit_code()
136def mypy(directory: T_directory = None) -> int:
137 """
138 Runs the mypy static type checker.
140 Args:
141 directory: where to run mypy on (default is current dir)
143 """
144 config = state.update_config(directory=directory)
145 return _check_tool("mypy", config.directory)
148@app.command()
149@with_exit_code()
150def bandit(directory: T_directory = None) -> int:
151 """
152 Runs the bandit security checker.
154 Args:
155 directory: where to run bandit on (default is current dir)
157 """
158 config = state.update_config(directory=directory)
159 return _check_tool("bandit", "-r", "-c", config.pyproject, config.directory)
162@app.command()
163@with_exit_code()
164def pydocstyle(directory: T_directory = None) -> int:
165 """
166 Runs the pydocstyle docstring checker.
168 Args:
169 directory: where to run pydocstyle on (default is current dir)
171 """
172 config = state.update_config(directory=directory)
173 return _check_tool("pydocstyle", config.directory)
176@app.command()
177@with_exit_code()
178def pytest(
179 directory: T_directory = None, html: bool = False, json: bool = False, coverage: int = None
180) -> int: # pragma: no cover
181 """
182 Runs all pytests.
184 Args:
185 directory: where to run pytests on (default is current dir)
186 html: generate HTML coverage output?
187 json: generate JSON coverage output?
188 coverage: threshold for coverage (in %)
190 Example:
191 > su6 pytest --coverage 50
192 if any checks fail: exit 1 and red circle
193 if all checks pass but coverage is less than 50%: exit 1, green circle for pytest and red for coverage
194 if all check pass and coverage is at least 50%: exit 0, green circle for pytest and green for coverage
196 if --coverage is not passed, there will be no circle for coverage.
197 """
198 config = state.update_config(directory=directory, coverage=coverage)
200 args = ["--cov", config.directory]
202 if config.coverage:
203 # json output required!
204 json = True
206 if html:
207 args.extend(["--cov-report", "html"])
209 if json:
210 args.extend(["--cov-report", "json"])
212 exit_code = _check_tool("pytest", *args)
214 if config.coverage:
215 with open("coverage.json") as f:
216 data = json_load(f)
217 percent_covered = round(data["totals"]["percent_covered"])
219 # if actual coverage is less than the the threshold, exit code should be success (0)
220 exit_code = percent_covered < config.coverage
221 circle = RED_CIRCLE if exit_code else GREEN_CIRCLE
222 if state.format == "text":
223 print(circle, "coverage")
225 return exit_code
228@app.command(name="all")
229@with_exit_code()
230def check_all(directory: T_directory = None, ignore_uninstalled: bool = False, coverage: float = None) -> bool:
231 """
232 Run all available checks.
234 Args:
235 directory: where to run the tools on (default is current dir)
236 ignore_uninstalled: use --ignore-uninstalled to skip exit code 127 (command not found)
237 coverage: pass to pytest()
239 """
240 config = state.update_config(directory=directory)
241 ignored_exit_codes = set()
242 if ignore_uninstalled:
243 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND)
245 tools = config.determine_which_to_run([ruff, black, mypy, bandit, isort, pydocstyle, pytest])
247 exit_codes = []
248 for tool in tools:
249 a = [directory]
250 kw = dict(_suppress=True, _ignore=ignored_exit_codes)
252 if tool is pytest: # pragma: no cover
253 kw["coverage"] = coverage
255 exit_codes.append(tool(*a, **kw))
257 if state.format == "json":
258 dump_tools_with_results(tools, exit_codes)
260 return any(exit_codes)
263@app.command(name="fix")
264@with_exit_code()
265def do_fix(directory: T_directory = None, ignore_uninstalled: bool = False) -> bool:
266 """
267 Do everything that's safe to fix (so not ruff because that may break semantics).
269 Args:
270 directory: where to run the tools on (default is current dir)
271 ignore_uninstalled: use --ignore-uninstalled to skip exit code 127 (command not found)
273 """
274 config = state.update_config(directory=directory)
276 ignored_exit_codes = set()
277 if ignore_uninstalled:
278 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND)
280 tools = config.determine_which_to_run([black, isort])
282 exit_codes = [tool(directory, fix=True, _suppress=True, _ignore=ignored_exit_codes) for tool in tools]
284 if state.format == "json":
285 dump_tools_with_results(tools, exit_codes)
287 return any(exit_codes)
290@app.callback()
291def main(config: str = None, verbosity: Verbosity = DEFAULT_VERBOSITY, format: Format = DEFAULT_FORMAT) -> None:
292 """
293 This callback will run before every command, setting the right global flags.
295 Args:
296 config: path to a different config toml file
297 verbosity: level of detail to print out (1 - 3)
298 format: output format
300 """
301 state.load_config(config_file=config, verbosity=verbosity, format=format)
304if __name__ == "__main__": # pragma: no cover
305 app()