Coverage for src/su6/cli.py: 100%
141 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-05-31 19:29 +0200
« prev ^ index » next coverage.py v7.2.7, created at 2023-05-31 19:29 +0200
1"""This file contains all Typer Commands."""
2import contextlib
3import math
4import os
5import sys
6import typing
7from json import load as json_load
9import typer
10from plumbum import local
11from plumbum.commands.processes import CommandNotFound, ProcessExecutionError
12from rich import print
14from .__about__ import __version__
15from .core import (
16 DEFAULT_BADGE,
17 DEFAULT_FORMAT,
18 DEFAULT_VERBOSITY,
19 EXIT_CODE_COMMAND_NOT_FOUND,
20 EXIT_CODE_ERROR,
21 EXIT_CODE_SUCCESS,
22 GREEN_CIRCLE,
23 RED_CIRCLE,
24 YELLOW_CIRCLE,
25 Format,
26 PlumbumError,
27 Verbosity,
28 dump_tools_with_results,
29 info,
30 log_cmd_output,
31 log_command,
32 print_json,
33 state,
34 warn,
35 with_exit_code,
36)
38app = typer.Typer()
41def _check_tool(tool: str, *args: str) -> int:
42 """
43 Abstraction to run one of the cli checking tools and process its output.
45 Args:
46 tool: the (bash) name of the tool to run.
47 args: cli args to pass to the cli bash tool
48 """
49 try:
50 cmd = local[tool]
52 if state.verbosity >= 3:
53 log_command(cmd, args)
55 result = cmd(*args)
57 if state.output_format == "text":
58 print(GREEN_CIRCLE, tool)
60 if state.verbosity > 2: # pragma: no cover
61 log_cmd_output(result)
63 return EXIT_CODE_SUCCESS # success
64 except CommandNotFound: # pragma: no cover
65 if state.verbosity > 2:
66 warn(f"Tool {tool} not installed!")
68 if state.output_format == "text":
69 print(YELLOW_CIRCLE, tool)
71 return EXIT_CODE_COMMAND_NOT_FOUND # command not found
72 except ProcessExecutionError as e:
73 if state.output_format == "text":
74 print(RED_CIRCLE, tool)
76 if state.verbosity > 1:
77 log_cmd_output(e.stdout, e.stderr)
78 return EXIT_CODE_ERROR # general error
81# 'directory' is an optional cli argument to many commands, so we define the type here for reuse:
82T_directory: typing.TypeAlias = typing.Annotated[str, typer.Argument()] # = "."
85@app.command()
86@with_exit_code()
87def ruff(directory: T_directory = None) -> int:
88 """
89 Runs the Ruff Linter.
91 Args:
92 directory: where to run ruff on (default is current dir)
94 """
95 config = state.update_config(directory=directory)
96 return _check_tool("ruff", config.directory)
99@app.command()
100@with_exit_code()
101def black(directory: T_directory = None, fix: bool = False) -> int:
102 """
103 Runs the Black code formatter.
105 Args:
106 directory: where to run black on (default is current dir)
107 fix: if --fix is passed, black will be used to reformat the file(s).
109 """
110 config = state.update_config(directory=directory)
112 args = [config.directory, r"--exclude=venv.+|.+\.bak"]
113 if not fix:
114 args.append("--check")
115 elif state.verbosity > 2:
116 info("note: running WITHOUT --check -> changing files")
118 return _check_tool("black", *args)
121@app.command()
122@with_exit_code()
123def isort(directory: T_directory = None, fix: bool = False) -> int:
124 """
125 Runs the import sort (isort) utility.
127 Args:
128 directory: where to run isort on (default is current dir)
129 fix: if --fix is passed, isort will be used to rearrange imports.
131 """
132 config = state.update_config(directory=directory)
133 args = [config.directory]
134 if not fix:
135 args.append("--check-only")
136 elif state.verbosity > 2:
137 info("note: running WITHOUT --check -> changing files")
139 return _check_tool("isort", *args)
142@app.command()
143@with_exit_code()
144def mypy(directory: T_directory = None) -> int:
145 """
146 Runs the mypy static type checker.
148 Args:
149 directory: where to run mypy on (default is current dir)
151 """
152 config = state.update_config(directory=directory)
153 return _check_tool("mypy", config.directory)
156@app.command()
157@with_exit_code()
158def bandit(directory: T_directory = None) -> int:
159 """
160 Runs the bandit security checker.
162 Args:
163 directory: where to run bandit on (default is current dir)
165 """
166 config = state.update_config(directory=directory)
167 return _check_tool("bandit", "-r", "-c", config.pyproject, config.directory)
170@app.command()
171@with_exit_code()
172def pydocstyle(directory: T_directory = None) -> int:
173 """
174 Runs the pydocstyle docstring checker.
176 Args:
177 directory: where to run pydocstyle on (default is current dir)
179 """
180 config = state.update_config(directory=directory)
181 return _check_tool("pydocstyle", config.directory)
184@app.command()
185@with_exit_code()
186def pytest(
187 directory: T_directory = None,
188 html: bool = False,
189 json: bool = False,
190 coverage: int = None,
191 badge: bool = None,
192) -> int: # pragma: no cover
193 """
194 Runs all pytests.
196 Args:
197 directory: where to run pytests on (default is current dir)
198 html: generate HTML coverage output?
199 json: generate JSON coverage output?
200 coverage: threshold for coverage (in %)
201 badge: generate coverage badge (svg)? If you want to change the name, do this in pyproject.toml
203 Example:
204 > su6 pytest --coverage 50
205 if any checks fail: exit 1 and red circle
206 if all checks pass but coverage is less than 50%: exit 1, green circle for pytest and red for coverage
207 if all check pass and coverage is at least 50%: exit 0, green circle for pytest and green for coverage
209 if --coverage is not passed, there will be no circle for coverage.
210 """
211 config = state.update_config(directory=directory, coverage=coverage, badge=badge)
213 if config.badge and config.coverage is None:
214 # not None but still check cov
215 config.coverage = 0
217 args = ["--cov", config.directory]
219 if config.coverage is not None:
220 # json output required!
221 json = True
223 if html:
224 args.extend(["--cov-report", "html"])
226 if json:
227 args.extend(["--cov-report", "json"])
229 exit_code = _check_tool("pytest", *args)
231 if config.coverage is not None:
232 with open("coverage.json") as f:
233 data = json_load(f)
234 percent_covered = math.floor(data["totals"]["percent_covered"])
236 # if actual coverage is less than the the threshold, exit code should be success (0)
237 exit_code = percent_covered < config.coverage
238 circle = RED_CIRCLE if exit_code else GREEN_CIRCLE
239 if state.output_format == "text":
240 print(circle, "coverage")
242 if config.badge:
243 if not isinstance(config.badge, str):
244 # it's still True for some reason?
245 config.badge = DEFAULT_BADGE
247 with contextlib.suppress(FileNotFoundError):
248 os.remove(config.badge)
250 result = local["coverage-badge"]("-o", config.badge)
251 if state.verbosity > 2:
252 info(result)
254 return exit_code
257@app.command(name="all")
258@with_exit_code()
259def check_all(
260 directory: T_directory = None, ignore_uninstalled: bool = False, coverage: float = None, badge: bool = None
261) -> bool:
262 """
263 Run all available checks.
265 Args:
266 directory: where to run the tools on (default is current dir)
267 ignore_uninstalled: use --ignore-uninstalled to skip exit code 127 (command not found)
268 coverage: pass to pytest()
269 badge: pass to pytest()
271 """
272 config = state.update_config(directory=directory)
273 ignored_exit_codes = set()
274 if ignore_uninstalled:
275 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND)
277 tools = config.determine_which_to_run([ruff, black, mypy, bandit, isort, pydocstyle, pytest])
279 exit_codes = []
280 for tool in tools:
281 a = [directory]
282 kw = dict(_suppress=True, _ignore=ignored_exit_codes)
284 if tool is pytest: # pragma: no cover
285 kw["coverage"] = coverage
286 kw["badge"] = badge
288 exit_codes.append(tool(*a, **kw))
290 if state.output_format == "json":
291 dump_tools_with_results(tools, exit_codes)
293 return any(exit_codes)
296@app.command(name="fix")
297@with_exit_code()
298def do_fix(directory: T_directory = None, ignore_uninstalled: bool = False) -> bool:
299 """
300 Do everything that's safe to fix (so not ruff because that may break semantics).
302 Args:
303 directory: where to run the tools on (default is current dir)
304 ignore_uninstalled: use --ignore-uninstalled to skip exit code 127 (command not found)
306 """
307 config = state.update_config(directory=directory)
309 ignored_exit_codes = set()
310 if ignore_uninstalled:
311 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND)
313 tools = config.determine_which_to_run([black, isort])
315 exit_codes = [tool(directory, fix=True, _suppress=True, _ignore=ignored_exit_codes) for tool in tools]
317 if state.output_format == "json":
318 dump_tools_with_results(tools, exit_codes)
320 return any(exit_codes)
323@app.command()
324@with_exit_code()
325def self_update(version: str = None) -> int:
326 """
327 Update `su6` to the latest (stable) version.
329 Args:
330 version: (optional) specific version to update to
331 """
332 python = sys.executable
333 pip = local[python]["-m", "pip"]
335 try:
336 pkg = "su6"
337 if version:
338 pkg = f"{pkg}=={version}"
340 args = ["install", "--upgrade", pkg]
341 if state.verbosity >= 3:
342 log_command(pip, args)
344 output = pip(*args)
345 if state.verbosity > 2:
346 info(output)
347 match state.output_format:
348 case "text":
349 print(GREEN_CIRCLE, "self-update")
350 # case json handled automatically by with_exit_code
351 return 0
352 except PlumbumError as e:
353 if state.verbosity > 3:
354 raise e
355 elif state.verbosity > 2:
356 warn(str(e))
357 match state.output_format:
358 case "text":
359 print(RED_CIRCLE, "self-update")
360 # case json handled automatically by with_exit_code
361 return 1
364def version_callback() -> None:
365 """
366 --version requested!
367 """
368 match state.output_format:
369 case "text":
370 print(f"su6 Version: {__version__}")
371 case "json":
372 print_json({"version": __version__})
373 raise typer.Exit(0)
376@app.callback(invoke_without_command=True)
377def main(
378 ctx: typer.Context,
379 config: str = None,
380 verbosity: Verbosity = DEFAULT_VERBOSITY,
381 output_format: typing.Annotated[Format, typer.Option("--format")] = DEFAULT_FORMAT,
382 version: bool = False,
383) -> None:
384 """
385 This callback will run before every command, setting the right global flags.
387 Args:
388 ctx: context to determine if a subcommand is passed, etc
389 config: path to a different config toml file
390 verbosity: level of detail to print out (1 - 3)
391 output_format: output format
392 version: display current version?
394 """
395 state.load_config(config_file=config, verbosity=verbosity, output_format=output_format)
396 if version:
397 version_callback()
399 if not ctx.invoked_subcommand:
400 warn("Missing subcommand. Try `su6 --help` for more info.")
403if __name__ == "__main__": # pragma: no cover
404 app()