Coverage for src/su6/cli.py: 100%
100 statements
« prev ^ index » next coverage.py v7.2.6, created at 2023-05-30 14:31 +0200
« prev ^ index » next coverage.py v7.2.6, created at 2023-05-30 14:31 +0200
1"""This file contains all Typer Commands."""
2import contextlib
3import math
4import os
5import typing
6from json import load as json_load
8import typer
9from plumbum import local
10from plumbum.commands.processes import CommandNotFound, ProcessExecutionError
11from rich import print
13from .core import (
14 DEFAULT_FORMAT,
15 DEFAULT_VERBOSITY,
16 EXIT_CODE_COMMAND_NOT_FOUND,
17 EXIT_CODE_ERROR,
18 EXIT_CODE_SUCCESS,
19 GREEN_CIRCLE,
20 RED_CIRCLE,
21 YELLOW_CIRCLE,
22 Format,
23 Verbosity,
24 dump_tools_with_results,
25 info,
26 log_cmd_output,
27 log_command,
28 state,
29 warn,
30 with_exit_code, DEFAULT_BADGE,
31)
33app = typer.Typer()
36def _check_tool(tool: str, *args: str) -> int:
37 """
38 Abstraction to run one of the cli checking tools and process its output.
40 Args:
41 tool: the (bash) name of the tool to run.
42 args: cli args to pass to the cli bash tool
43 """
44 try:
45 cmd = local[tool]
47 if state.verbosity >= 3:
48 log_command(cmd, args)
50 result = cmd(*args)
52 if state.format == "text":
53 print(GREEN_CIRCLE, tool)
55 if state.verbosity > 2: # pragma: no cover
56 log_cmd_output(result)
58 return EXIT_CODE_SUCCESS # success
59 except CommandNotFound: # pragma: no cover
60 if state.verbosity > 2:
61 warn(f"Tool {tool} not installed!")
63 if state.format == "text":
64 print(YELLOW_CIRCLE, tool)
66 return EXIT_CODE_COMMAND_NOT_FOUND # command not found
67 except ProcessExecutionError as e:
68 if state.format == "text":
69 print(RED_CIRCLE, tool)
71 if state.verbosity > 1:
72 log_cmd_output(e.stdout, e.stderr)
73 return EXIT_CODE_ERROR # general error
76# 'directory' is an optional cli argument to many commands, so we define the type here for reuse:
77T_directory: typing.TypeAlias = typing.Annotated[str, typer.Argument()] # = "."
80@app.command()
81@with_exit_code()
82def ruff(directory: T_directory = None) -> int:
83 """
84 Runs the Ruff Linter.
86 Args:
87 directory: where to run ruff on (default is current dir)
89 """
90 config = state.update_config(directory=directory)
91 return _check_tool("ruff", config.directory)
94@app.command()
95@with_exit_code()
96def black(directory: T_directory = None, fix: bool = False) -> int:
97 """
98 Runs the Black code formatter.
100 Args:
101 directory: where to run black on (default is current dir)
102 fix: if --fix is passed, black will be used to reformat the file(s).
104 """
105 config = state.update_config(directory=directory)
107 args = [config.directory, r"--exclude=venv.+|.+\.bak"]
108 if not fix:
109 args.append("--check")
110 elif state.verbosity > 2:
111 info("note: running WITHOUT --check -> changing files")
113 return _check_tool("black", *args)
116@app.command()
117@with_exit_code()
118def isort(directory: T_directory = None, fix: bool = False) -> int:
119 """
120 Runs the import sort (isort) utility.
122 Args:
123 directory: where to run isort on (default is current dir)
124 fix: if --fix is passed, isort will be used to rearrange imports.
126 """
127 config = state.update_config(directory=directory)
128 args = [config.directory]
129 if not fix:
130 args.append("--check-only")
131 elif state.verbosity > 2:
132 info("note: running WITHOUT --check -> changing files")
134 return _check_tool("isort", *args)
137@app.command()
138@with_exit_code()
139def mypy(directory: T_directory = None) -> int:
140 """
141 Runs the mypy static type checker.
143 Args:
144 directory: where to run mypy on (default is current dir)
146 """
147 config = state.update_config(directory=directory)
148 return _check_tool("mypy", config.directory)
151@app.command()
152@with_exit_code()
153def bandit(directory: T_directory = None) -> int:
154 """
155 Runs the bandit security checker.
157 Args:
158 directory: where to run bandit on (default is current dir)
160 """
161 config = state.update_config(directory=directory)
162 return _check_tool("bandit", "-r", "-c", config.pyproject, config.directory)
165@app.command()
166@with_exit_code()
167def pydocstyle(directory: T_directory = None) -> int:
168 """
169 Runs the pydocstyle docstring checker.
171 Args:
172 directory: where to run pydocstyle on (default is current dir)
174 """
175 config = state.update_config(directory=directory)
176 return _check_tool("pydocstyle", config.directory)
179@app.command()
180@with_exit_code()
181def pytest(
182 directory: T_directory = None,
183 html: bool = False,
184 json: bool = False,
185 coverage: int = None,
186 badge: bool = None,
187) -> int: # pragma: no cover
188 """
189 Runs all pytests.
191 Args:
192 directory: where to run pytests on (default is current dir)
193 html: generate HTML coverage output?
194 json: generate JSON coverage output?
195 coverage: threshold for coverage (in %)
196 badge: generate coverage badge (svg)? If you want to change the name, do this in pyproject.toml
198 Example:
199 > su6 pytest --coverage 50
200 if any checks fail: exit 1 and red circle
201 if all checks pass but coverage is less than 50%: exit 1, green circle for pytest and red for coverage
202 if all check pass and coverage is at least 50%: exit 0, green circle for pytest and green for coverage
204 if --coverage is not passed, there will be no circle for coverage.
205 """
206 config = state.update_config(directory=directory, coverage=coverage, badge=badge)
208 if config.badge and config.coverage is None:
209 # not None but still check cov
210 config.coverage = 0
212 args = ["--cov", config.directory]
214 if config.coverage is not None:
215 # json output required!
216 json = True
218 if html:
219 args.extend(["--cov-report", "html"])
221 if json:
222 args.extend(["--cov-report", "json"])
224 exit_code = _check_tool("pytest", *args)
226 if config.coverage is not None:
227 with open("coverage.json") as f:
228 data = json_load(f)
229 percent_covered = math.floor(data["totals"]["percent_covered"])
231 # if actual coverage is less than the the threshold, exit code should be success (0)
232 exit_code = percent_covered < config.coverage
233 circle = RED_CIRCLE if exit_code else GREEN_CIRCLE
234 if state.format == "text":
235 print(circle, "coverage")
237 if config.badge:
238 if not isinstance(config.badge, str):
239 # it's still True for some reason?
240 config.badge = DEFAULT_BADGE
242 with contextlib.suppress(FileNotFoundError):
243 os.remove(config.badge)
245 result = local["coverage-badge"]("-o", config.badge)
246 if state.verbosity > 2:
247 print(result)
249 return exit_code
252@app.command(name="all")
253@with_exit_code()
254def check_all(
255 directory: T_directory = None, ignore_uninstalled: bool = False, coverage: float = None, badge: bool = None
256) -> bool:
257 """
258 Run all available checks.
260 Args:
261 directory: where to run the tools on (default is current dir)
262 ignore_uninstalled: use --ignore-uninstalled to skip exit code 127 (command not found)
263 coverage: pass to pytest()
264 badge: pass to pytest()
266 """
267 config = state.update_config(directory=directory)
268 ignored_exit_codes = set()
269 if ignore_uninstalled:
270 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND)
272 tools = config.determine_which_to_run([ruff, black, mypy, bandit, isort, pydocstyle, pytest])
274 exit_codes = []
275 for tool in tools:
276 a = [directory]
277 kw = dict(_suppress=True, _ignore=ignored_exit_codes)
279 if tool is pytest: # pragma: no cover
280 kw["coverage"] = coverage
281 kw["badge"] = badge
283 exit_codes.append(tool(*a, **kw))
285 if state.format == "json":
286 dump_tools_with_results(tools, exit_codes)
288 return any(exit_codes)
291@app.command(name="fix")
292@with_exit_code()
293def do_fix(directory: T_directory = None, ignore_uninstalled: bool = False) -> bool:
294 """
295 Do everything that's safe to fix (so not ruff because that may break semantics).
297 Args:
298 directory: where to run the tools on (default is current dir)
299 ignore_uninstalled: use --ignore-uninstalled to skip exit code 127 (command not found)
301 """
302 config = state.update_config(directory=directory)
304 ignored_exit_codes = set()
305 if ignore_uninstalled:
306 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND)
308 tools = config.determine_which_to_run([black, isort])
310 exit_codes = [tool(directory, fix=True, _suppress=True, _ignore=ignored_exit_codes) for tool in tools]
312 if state.format == "json":
313 dump_tools_with_results(tools, exit_codes)
315 return any(exit_codes)
318@app.callback()
319def main(config: str = None, verbosity: Verbosity = DEFAULT_VERBOSITY, format: Format = DEFAULT_FORMAT) -> None:
320 """
321 This callback will run before every command, setting the right global flags.
323 Args:
324 config: path to a different config toml file
325 verbosity: level of detail to print out (1 - 3)
326 format: output format
328 """
329 state.load_config(config_file=config, verbosity=verbosity, format=format)
332if __name__ == "__main__": # pragma: no cover
333 app()