Coverage for src/su6/cli.py: 100%
155 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-05 12:28 +0200
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-05 12:28 +0200
1"""This file contains all Typer Commands."""
2import contextlib
3import math
4import os
5import sys
6import typing
7from dataclasses import asdict
8from importlib.metadata import entry_points
9from json import load as json_load
11import typer
12from plumbum import local
13from plumbum.machines import LocalCommand
14from rich import print
16from .__about__ import __version__
17from .core import (
18 DEFAULT_BADGE,
19 DEFAULT_FORMAT,
20 DEFAULT_VERBOSITY,
21 EXIT_CODE_COMMAND_NOT_FOUND,
22 GREEN_CIRCLE,
23 RED_CIRCLE,
24 Format,
25 PlumbumError,
26 Verbosity,
27 dump_tools_with_results,
28 info,
29 log_command,
30 print_json,
31 run_tool,
32 state,
33 warn,
34 with_exit_code,
35)
36from .plugins import include_plugins
38app = typer.Typer()
40include_plugins(app)
42# 'directory' is an optional cli argument to many commands, so we define the type here for reuse:
43T_directory: typing.TypeAlias = typing.Annotated[str, typer.Argument()] # = "."
46@app.command()
47@with_exit_code()
48def ruff(directory: T_directory = None) -> int:
49 """
50 Runs the Ruff Linter.
52 Args:
53 directory: where to run ruff on (default is current dir)
55 """
56 config = state.update_config(directory=directory)
57 return run_tool("ruff", config.directory)
60@app.command()
61@with_exit_code()
62def black(directory: T_directory = None, fix: bool = False) -> int:
63 """
64 Runs the Black code formatter.
66 Args:
67 directory: where to run black on (default is current dir)
68 fix: if --fix is passed, black will be used to reformat the file(s).
70 """
71 config = state.update_config(directory=directory)
73 args = [config.directory, r"--exclude=venv.+|.+\.bak"]
74 if not fix:
75 args.append("--check")
76 elif state.verbosity > 2:
77 info("note: running WITHOUT --check -> changing files")
79 return run_tool("black", *args)
82@app.command()
83@with_exit_code()
84def isort(directory: T_directory = None, fix: bool = False) -> int:
85 """
86 Runs the import sort (isort) utility.
88 Args:
89 directory: where to run isort on (default is current dir)
90 fix: if --fix is passed, isort will be used to rearrange imports.
92 """
93 config = state.update_config(directory=directory)
94 args = [config.directory]
95 if not fix:
96 args.append("--check-only")
97 elif state.verbosity > 2:
98 info("note: running WITHOUT --check -> changing files")
100 return run_tool("isort", *args)
103@app.command()
104@with_exit_code()
105def mypy(directory: T_directory = None) -> int:
106 """
107 Runs the mypy static type checker.
109 Args:
110 directory: where to run mypy on (default is current dir)
112 """
113 config = state.update_config(directory=directory)
114 return run_tool("mypy", config.directory)
117@app.command()
118@with_exit_code()
119def bandit(directory: T_directory = None) -> int:
120 """
121 Runs the bandit security checker.
123 Args:
124 directory: where to run bandit on (default is current dir)
126 """
127 config = state.update_config(directory=directory)
128 return run_tool("bandit", "-r", "-c", config.pyproject, config.directory)
131@app.command()
132@with_exit_code()
133def pydocstyle(directory: T_directory = None) -> int:
134 """
135 Runs the pydocstyle docstring checker.
137 Args:
138 directory: where to run pydocstyle on (default is current dir)
140 """
141 config = state.update_config(directory=directory)
142 return run_tool("pydocstyle", config.directory)
145@app.command(name="all")
146@with_exit_code()
147def check_all(
148 directory: T_directory = None,
149 ignore_uninstalled: bool = False,
150 stop_after_first_failure: bool = None,
151 # pytest:
152 coverage: float = None,
153 badge: bool = None,
154) -> bool:
155 """
156 Run all available checks.
158 Args:
159 directory: where to run the tools on (default is current dir)
160 ignore_uninstalled: use --ignore-uninstalled to skip exit code 127 (command not found)
161 stop_after_first_failure: by default, the tool continues to run each test.
162 But if you only want to know if everything passes,
163 you could set this flag (or in the config toml) to stop early.
165 coverage: pass to pytest()
166 badge: pass to pytest()
168 `def all()` is not allowed since this overshadows a builtin
169 """
170 config = state.update_config(
171 directory=directory,
172 stop_after_first_failure=stop_after_first_failure,
173 coverage=coverage,
174 badge=badge,
175 )
177 ignored_exit_codes = set()
178 if ignore_uninstalled:
179 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND)
181 tools = config.determine_which_to_run([ruff, black, mypy, bandit, isort, pydocstyle, pytest])
183 exit_codes = []
184 for tool in tools:
185 a = [directory]
186 kw = dict(_suppress=True, _ignore=ignored_exit_codes)
188 if tool is pytest: # pragma: no cover
189 kw["coverage"] = config.coverage
190 kw["badge"] = config.badge
192 result = tool(*a, **kw)
193 exit_codes.append(result)
194 if config.stop_after_first_failure and result != 0:
195 break
197 if state.output_format == "json":
198 dump_tools_with_results(tools, exit_codes)
200 return any(exit_codes)
203@app.command()
204@with_exit_code()
205def pytest(
206 directory: T_directory = None,
207 html: bool = False,
208 json: bool = False,
209 coverage: int = None,
210 badge: bool = None,
211 k: typing.Annotated[str, typer.Option("-k")] = None, # fw to pytest
212 s: typing.Annotated[bool, typer.Option("-s")] = False, # fw to pytest
213) -> int: # pragma: no cover
214 """
215 Runs all pytests.
217 Args:
218 directory: where to run pytests on (default is current dir)
219 html: generate HTML coverage output?
220 json: generate JSON coverage output?
221 coverage: threshold for coverage (in %)
222 badge: generate coverage badge (svg)? If you want to change the name, do this in pyproject.toml
224 k: pytest -k <str> option
225 s: pytest -s option
227 Example:
228 > su6 pytest --coverage 50
229 if any checks fail: exit 1 and red circle
230 if all checks pass but coverage is less than 50%: exit 1, green circle for pytest and red for coverage
231 if all check pass and coverage is at least 50%: exit 0, green circle for pytest and green for coverage
233 if --coverage is not passed, there will be no circle for coverage.
234 """
235 config = state.update_config(directory=directory, coverage=coverage, badge=badge)
237 if config.badge and config.coverage is None:
238 # not None but still check cov
239 config.coverage = 0
241 args = ["--cov", config.directory]
243 if config.coverage is not None:
244 # json output required!
245 json = True
247 if k:
248 args.extend(["-k", k])
249 if s:
250 args.append("-s")
252 if html:
253 args.extend(["--cov-report", "html"])
255 if json:
256 args.extend(["--cov-report", "json"])
258 exit_code = run_tool("pytest", *args)
260 if config.coverage is not None:
261 with open("coverage.json") as f:
262 data = json_load(f)
263 percent_covered = math.floor(data["totals"]["percent_covered"])
265 # if actual coverage is less than the the threshold, exit code should be success (0)
266 exit_code = percent_covered < config.coverage
267 circle = RED_CIRCLE if exit_code else GREEN_CIRCLE
268 if state.output_format == "text":
269 print(circle, "coverage")
271 if config.badge:
272 if not isinstance(config.badge, str):
273 # it's still True for some reason?
274 config.badge = DEFAULT_BADGE
276 with contextlib.suppress(FileNotFoundError):
277 os.remove(config.badge)
279 result = local["coverage-badge"]("-o", config.badge)
280 if state.verbosity > 2:
281 info(result)
283 return exit_code
286@app.command(name="fix")
287@with_exit_code()
288def do_fix(directory: T_directory = None, ignore_uninstalled: bool = False) -> bool:
289 """
290 Do everything that's safe to fix (not ruff because that may break semantics).
292 Args:
293 directory: where to run the tools on (default is current dir)
294 ignore_uninstalled: use --ignore-uninstalled to skip exit code 127 (command not found)
296 `def fix()` is not recommended because other commands have 'fix' as an argument so those names would collide.
297 """
298 config = state.update_config(directory=directory)
300 ignored_exit_codes = set()
301 if ignore_uninstalled:
302 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND)
304 tools = config.determine_which_to_run([black, isort])
306 exit_codes = [tool(directory, fix=True, _suppress=True, _ignore=ignored_exit_codes) for tool in tools]
308 if state.output_format == "json":
309 dump_tools_with_results(tools, exit_codes)
311 return any(exit_codes)
314@app.command()
315@with_exit_code()
316def plugins() -> None:
317 """
318 List installed plugin modules.
320 """
321 modules = entry_points(group="su6")
322 match state.output_format:
323 case "text":
324 if modules:
325 print("Installed Plugins:")
326 [print("-", _) for _ in modules]
327 else: # pragma: nocover
328 print("No Installed Plugins.")
329 case "json":
330 print_json(
331 {
332 _.name: {
333 "name": _.name,
334 "value": _.value,
335 "group": _.group,
336 }
337 for _ in modules
338 }
339 )
342def _pip() -> LocalCommand:
343 """
344 Return a `pip` command.
345 """
346 python = sys.executable
347 return local[python]["-m", "pip"]
350@app.command()
351@with_exit_code()
352def self_update(version: str = None) -> int:
353 """
354 Update `su6` to the latest (stable) version.
356 Args:
357 version: (optional) specific version to update to
358 """
359 pip = _pip()
361 try:
362 pkg = "su6"
363 if version:
364 pkg = f"{pkg}=={version}"
366 args = ["install", "--upgrade", pkg]
367 if state.verbosity >= 3:
368 log_command(pip, args)
370 output = pip(*args)
371 if state.verbosity > 2:
372 info(output)
373 match state.output_format:
374 case "text":
375 print(GREEN_CIRCLE, "self-update")
376 # case json handled automatically by with_exit_code
377 return 0
378 except PlumbumError as e:
379 if state.verbosity > 3:
380 raise e
381 elif state.verbosity > 2:
382 warn(str(e))
383 match state.output_format:
384 case "text":
385 print(RED_CIRCLE, "self-update")
386 # case json handled automatically by with_exit_code
387 return 1
390def version_callback() -> typing.Never:
391 """
392 --version requested!
393 """
394 match state.output_format:
395 case "text":
396 print(f"su6 Version: {__version__}")
397 case "json":
398 print_json({"version": __version__})
399 raise typer.Exit(0)
402def show_config_callback() -> typing.Never:
403 """
404 --show-config requested!
405 """
406 match state.output_format:
407 case "text":
408 print(state)
409 case "json":
410 print_json(asdict(state))
411 raise typer.Exit(0)
414@app.callback(invoke_without_command=True)
415def main(
416 ctx: typer.Context,
417 config: str = None,
418 verbosity: Verbosity = DEFAULT_VERBOSITY,
419 output_format: typing.Annotated[Format, typer.Option("--format")] = DEFAULT_FORMAT,
420 # stops the program:
421 show_config: bool = False,
422 version: bool = False,
423) -> None:
424 """
425 This callback will run before every command, setting the right global flags.
427 Args:
428 ctx: context to determine if a subcommand is passed, etc
429 config: path to a different config toml file
430 verbosity: level of detail to print out (1 - 3)
431 output_format: output format
433 show_config: display current configuration?
434 version: display current version?
436 """
437 state.load_config(config_file=config, verbosity=verbosity, output_format=output_format)
439 if show_config:
440 show_config_callback()
441 elif version:
442 version_callback()
443 elif not ctx.invoked_subcommand:
444 warn("Missing subcommand. Try `su6 --help` for more info.")
445 # else: just continue
448if __name__ == "__main__": # pragma: no cover
449 app()