Coverage for src/su6/cli.py: 100%
157 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-08 13:53 +0200
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-08 13:53 +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 Singleton,
27 Verbosity,
28 dump_tools_with_results,
29 info,
30 log_command,
31 print_json,
32 run_tool,
33 state,
34 warn,
35 with_exit_code,
36)
37from .plugins import include_plugins
39app = typer.Typer()
41include_plugins(app)
43# 'directory' is an optional cli argument to many commands, so we define the type here for reuse:
44T_directory: typing.TypeAlias = typing.Annotated[str, typer.Argument()]
47@app.command()
48@with_exit_code()
49def ruff(directory: T_directory = None) -> int:
50 """
51 Runs the Ruff Linter.
53 Args:
54 directory: where to run ruff on (default is current dir)
56 """
57 config = state.update_config(directory=directory)
58 return run_tool("ruff", config.directory)
61@app.command()
62@with_exit_code()
63def black(directory: T_directory = None, fix: bool = False) -> int:
64 """
65 Runs the Black code formatter.
67 Args:
68 directory: where to run black on (default is current dir)
69 fix: if --fix is passed, black will be used to reformat the file(s).
71 """
72 config = state.update_config(directory=directory)
74 args = [config.directory, r"--exclude=venv.+|.+\.bak"]
75 if not fix:
76 args.append("--check")
77 elif state.verbosity > 2:
78 info("note: running WITHOUT --check -> changing files")
80 return run_tool("black", *args)
83@app.command()
84@with_exit_code()
85def isort(directory: T_directory = None, fix: bool = False) -> int:
86 """
87 Runs the import sort (isort) utility.
89 Args:
90 directory: where to run isort on (default is current dir)
91 fix: if --fix is passed, isort will be used to rearrange imports.
93 """
94 config = state.update_config(directory=directory)
95 args = [config.directory]
96 if not fix:
97 args.append("--check-only")
98 elif state.verbosity > 2:
99 info("note: running WITHOUT --check -> changing files")
101 return run_tool("isort", *args)
104@app.command()
105@with_exit_code()
106def mypy(directory: T_directory = None) -> int:
107 """
108 Runs the mypy static type checker.
110 Args:
111 directory: where to run mypy on (default is current dir)
113 """
114 config = state.update_config(directory=directory)
115 return run_tool("mypy", config.directory)
118@app.command()
119@with_exit_code()
120def bandit(directory: T_directory = None) -> int:
121 """
122 Runs the bandit security checker.
124 Args:
125 directory: where to run bandit on (default is current dir)
127 """
128 config = state.update_config(directory=directory)
129 return run_tool("bandit", "-r", "-c", config.pyproject, config.directory)
132@app.command()
133@with_exit_code()
134def pydocstyle(directory: T_directory = None) -> int:
135 """
136 Runs the pydocstyle docstring checker.
138 Args:
139 directory: where to run pydocstyle on (default is current dir)
141 """
142 config = state.update_config(directory=directory)
143 return run_tool("pydocstyle", config.directory)
146@app.command(name="all")
147@with_exit_code()
148def check_all(
149 directory: T_directory = None,
150 ignore_uninstalled: bool = False,
151 stop_after_first_failure: bool = None,
152 # pytest:
153 coverage: float = None,
154 badge: bool = None,
155) -> bool:
156 """
157 Run all available checks.
159 Args:
160 directory: where to run the tools on (default is current dir)
161 ignore_uninstalled: use --ignore-uninstalled to skip exit code 127 (command not found)
162 stop_after_first_failure: by default, the tool continues to run each test.
163 But if you only want to know if everything passes,
164 you could set this flag (or in the config toml) to stop early.
166 coverage: pass to pytest()
167 badge: pass to pytest()
169 `def all()` is not allowed since this overshadows a builtin
170 """
171 config = state.update_config(
172 directory=directory,
173 stop_after_first_failure=stop_after_first_failure,
174 coverage=coverage,
175 badge=badge,
176 )
178 ignored_exit_codes = set()
179 if ignore_uninstalled:
180 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND)
182 tools = config.determine_which_to_run([ruff, black, mypy, bandit, isort, pydocstyle, pytest])
184 exit_codes = []
185 for tool in tools:
186 a = [directory]
187 kw = dict(_suppress=True, _ignore=ignored_exit_codes)
189 if tool is pytest: # pragma: no cover
190 kw["coverage"] = config.coverage
191 kw["badge"] = config.badge
193 result = tool(*a, **kw)
194 exit_codes.append(result)
195 if config.stop_after_first_failure and result != 0:
196 break
198 if state.output_format == "json":
199 dump_tools_with_results(tools, exit_codes)
201 return any(exit_codes)
204@app.command()
205@with_exit_code()
206def pytest(
207 directory: T_directory = None,
208 html: bool = False,
209 json: bool = False,
210 coverage: int = None,
211 badge: bool = None,
212 k: typing.Annotated[str, typer.Option("-k")] = None, # fw to pytest
213 s: typing.Annotated[bool, typer.Option("-s")] = False, # fw to pytest
214 v: typing.Annotated[bool, typer.Option("-v")] = False, # fw to pytest
215 x: typing.Annotated[bool, typer.Option("-x")] = False, # fw to pytest
216) -> int: # pragma: no cover
217 """
218 Runs all pytests.
220 Args:
221 directory: where to run pytests on (default is current dir)
222 html: generate HTML coverage output?
223 json: generate JSON coverage output?
224 coverage: threshold for coverage (in %)
225 badge: generate coverage badge (svg)? If you want to change the name, do this in pyproject.toml
227 k: pytest -k <str> option (run specific tests)
228 s: pytest -s option (show output)
229 v: pytest -v option (verbose)
230 x: pytest -x option (stop after first failure)
232 Example:
233 > su6 pytest --coverage 50
234 if any checks fail: exit 1 and red circle
235 if all checks pass but coverage is less than 50%: exit 1, green circle for pytest and red for coverage
236 if all check pass and coverage is at least 50%: exit 0, green circle for pytest and green for coverage
238 if --coverage is not passed, there will be no circle for coverage.
239 """
240 config = state.update_config(directory=directory, coverage=coverage, badge=badge)
242 if config.badge and config.coverage is None:
243 # not None but still check cov
244 config.coverage = 0
246 args = ["--cov", config.directory]
248 if config.coverage is not None:
249 # json output required!
250 json = True
252 if k:
253 args.extend(["-k", k])
254 if s:
255 args.append("-s")
256 if v:
257 args.append("-v")
258 if x:
259 args.append("-x")
261 if html:
262 args.extend(["--cov-report", "html"])
264 if json:
265 args.extend(["--cov-report", "json"])
267 exit_code = run_tool("pytest", *args)
269 if config.coverage is not None:
270 with open("coverage.json") as f:
271 data = json_load(f)
272 percent_covered = math.floor(data["totals"]["percent_covered"])
274 # if actual coverage is less than the the threshold, exit code should be success (0)
275 exit_code = percent_covered < config.coverage
276 circle = RED_CIRCLE if exit_code else GREEN_CIRCLE
277 if state.output_format == "text":
278 print(circle, "coverage")
280 if config.badge:
281 if not isinstance(config.badge, str):
282 # it's still True for some reason?
283 config.badge = DEFAULT_BADGE
285 with contextlib.suppress(FileNotFoundError):
286 os.remove(config.badge)
288 result = local["coverage-badge"]("-o", config.badge)
289 if state.verbosity > 2:
290 info(result)
292 return exit_code
295@app.command(name="fix")
296@with_exit_code()
297def do_fix(directory: T_directory = None, ignore_uninstalled: bool = False) -> bool:
298 """
299 Do everything that's safe to fix (not ruff because that may break semantics).
301 Args:
302 directory: where to run the tools on (default is current dir)
303 ignore_uninstalled: use --ignore-uninstalled to skip exit code 127 (command not found)
305 `def fix()` is not recommended because other commands have 'fix' as an argument so those names would collide.
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 plugins() -> None:
326 """
327 List installed plugin modules.
329 """
330 modules = entry_points(group="su6")
331 match state.output_format:
332 case "text":
333 if modules:
334 print("Installed Plugins:")
335 [print("-", _) for _ in modules]
336 else: # pragma: nocover
337 print("No Installed Plugins.")
338 case "json":
339 print_json(
340 {
341 _.name: {
342 "name": _.name,
343 "value": _.value,
344 "group": _.group,
345 }
346 for _ in modules
347 }
348 )
351def _pip() -> LocalCommand:
352 """
353 Return a `pip` command.
354 """
355 python = sys.executable
356 return local[python]["-m", "pip"]
359@app.command()
360@with_exit_code()
361def self_update(version: str = None) -> int:
362 """
363 Update `su6` to the latest (stable) version.
365 Args:
366 version: (optional) specific version to update to
367 """
368 pip = _pip()
370 try:
371 pkg = "su6"
372 if version:
373 pkg = f"{pkg}=={version}"
375 args = ["install", "--upgrade", pkg]
376 if state.verbosity >= 3:
377 log_command(pip, args)
379 output = pip(*args)
380 if state.verbosity > 2:
381 info(output)
382 match state.output_format:
383 case "text":
384 print(GREEN_CIRCLE, "self-update")
385 # case json handled automatically by with_exit_code
386 return 0
387 except PlumbumError as e:
388 if state.verbosity > 3:
389 raise e
390 elif state.verbosity > 2:
391 warn(str(e))
392 match state.output_format:
393 case "text":
394 print(RED_CIRCLE, "self-update")
395 # case json handled automatically by with_exit_code
396 return 1
399def version_callback() -> typing.Never:
400 """
401 --version requested!
402 """
403 match state.output_format:
404 case "text":
405 print(f"su6 Version: {__version__}")
406 case "json":
407 print_json({"version": __version__})
408 raise typer.Exit(0)
411def show_config_callback() -> typing.Never:
412 """
413 --show-config requested!
414 """
415 match state.output_format:
416 case "text":
417 print(state)
418 case "json":
419 print_json(asdict(state))
420 raise typer.Exit(0)
423@app.callback(invoke_without_command=True)
424def main(
425 ctx: typer.Context,
426 config: str = None,
427 verbosity: Verbosity = DEFAULT_VERBOSITY,
428 output_format: typing.Annotated[Format, typer.Option("--format")] = DEFAULT_FORMAT,
429 # stops the program:
430 show_config: bool = False,
431 version: bool = False,
432) -> None:
433 """
434 This callback will run before every command, setting the right global flags.
436 Args:
437 ctx: context to determine if a subcommand is passed, etc
438 config: path to a different config toml file
439 verbosity: level of detail to print out (1 - 3)
440 output_format: output format
442 show_config: display current configuration?
443 version: display current version?
445 """
446 if state.config:
447 # if a config already exists, it's outdated, so we clear it.
448 # we don't clear everything since Plugin configs may be already cached.
449 Singleton.clear(state.config)
451 state.load_config(config_file=config, verbosity=verbosity, output_format=output_format)
453 if show_config:
454 show_config_callback()
455 elif version:
456 version_callback()
457 elif not ctx.invoked_subcommand:
458 warn("Missing subcommand. Try `su6 --help` for more info.")
459 # else: just continue
462if __name__ == "__main__": # pragma: no cover
463 app()