Coverage for src/su6/cli.py: 100%
170 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-07-17 13:55 +0200
« prev ^ index » next coverage.py v7.2.7, created at 2023-07-17 13:55 +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 configuraptor import Singleton
13from plumbum import local
14from plumbum.machines import LocalCommand
15from rich import print
16from typing_extensions import Never
18from .__about__ import __version__
19from .core import (
20 DEFAULT_BADGE,
21 DEFAULT_FORMAT,
22 DEFAULT_VERBOSITY,
23 EXIT_CODE_COMMAND_NOT_FOUND,
24 GREEN_CIRCLE,
25 RED_CIRCLE,
26 Format,
27 PlumbumError,
28 Verbosity,
29 dump_tools_with_results,
30 info,
31 log_command,
32 print_json,
33 run_tool,
34 state,
35 warn,
36 with_exit_code,
37)
38from .plugins import include_plugins
40app = typer.Typer()
42include_plugins(app)
44# 'directory' is an optional cli argument to many commands, so we define the type here for reuse:
45T_directory: typing.TypeAlias = typing.Annotated[str, typer.Argument()]
48@app.command()
49@with_exit_code()
50def ruff(directory: T_directory = None) -> int:
51 """
52 Runs the Ruff Linter.
54 Args:
55 directory: where to run ruff on (default is current dir)
57 """
58 config = state.update_config(directory=directory)
59 return run_tool("ruff", config.directory)
62@app.command()
63@with_exit_code()
64def black(directory: T_directory = None, fix: bool = False) -> int:
65 """
66 Runs the Black code formatter.
68 Args:
69 directory: where to run black on (default is current dir)
70 fix: if --fix is passed, black will be used to reformat the file(s).
72 """
73 config = state.update_config(directory=directory)
75 args = [config.directory, r"--exclude=venv.+|.+\.bak"]
76 if not fix:
77 args.append("--check")
78 elif state.verbosity > 2:
79 info("note: running WITHOUT --check -> changing files")
81 return run_tool("black", *args)
84@app.command()
85@with_exit_code()
86def isort(directory: T_directory = None, fix: bool = False) -> int:
87 """
88 Runs the import sort (isort) utility.
90 Args:
91 directory: where to run isort on (default is current dir)
92 fix: if --fix is passed, isort will be used to rearrange imports.
94 """
95 config = state.update_config(directory=directory)
96 args = [config.directory]
97 if not fix:
98 args.append("--check-only")
99 elif state.verbosity > 2:
100 info("note: running WITHOUT --check -> changing files")
102 return run_tool("isort", *args)
105@app.command()
106@with_exit_code()
107def mypy(directory: T_directory = None) -> int:
108 """
109 Runs the mypy static type checker.
111 Args:
112 directory: where to run mypy on (default is current dir)
114 """
115 config = state.update_config(directory=directory)
116 return run_tool("mypy", config.directory)
119@app.command()
120@with_exit_code()
121def bandit(directory: T_directory = None) -> int:
122 """
123 Runs the bandit security checker.
125 Args:
126 directory: where to run bandit on (default is current dir)
128 """
129 config = state.update_config(directory=directory)
130 return run_tool("bandit", "-r", "-c", config.pyproject, config.directory)
133@app.command()
134@with_exit_code()
135def pydocstyle(directory: T_directory = None) -> int:
136 """
137 Runs the pydocstyle docstring checker.
139 Args:
140 directory: where to run pydocstyle on (default is current dir)
142 """
143 config = state.update_config(directory=directory)
144 return run_tool("pydocstyle", config.directory)
147@app.command(name="list")
148@with_exit_code()
149def list_tools() -> None:
150 """
151 List tools that would run with 'su6 all'.
152 """
153 config = state.update_config()
154 all_tools = [ruff, black, mypy, bandit, isort, pydocstyle, pytest]
155 all_plugin_tools = [_.wrapped for _ in state._registered_plugins.values() if _.what == "command"]
156 tools_to_run = config.determine_which_to_run(all_tools) + config.determine_plugins_to_run("add_to_all")
158 output = {}
159 for tool in all_tools + all_plugin_tools:
160 tool_name = tool.__name__.replace("_", "-")
162 if state.output_format == "text":
163 if tool in tools_to_run:
164 print(GREEN_CIRCLE, tool_name)
165 else:
166 print(RED_CIRCLE, tool_name)
168 elif state.output_format == "json":
169 output[tool_name] = tool in tools_to_run
171 if state.output_format == "json":
172 print_json(output)
175@app.command(name="all")
176@with_exit_code()
177def check_all(
178 directory: T_directory = None,
179 ignore_uninstalled: bool = False,
180 stop_after_first_failure: bool = None,
181 # pytest:
182 coverage: float = None,
183 badge: bool = None,
184) -> bool:
185 """
186 Run all available checks.
188 Args:
189 directory: where to run the tools on (default is current dir)
190 ignore_uninstalled: use --ignore-uninstalled to skip exit code 127 (command not found)
191 stop_after_first_failure: by default, the tool continues to run each test.
192 But if you only want to know if everything passes,
193 you could set this flag (or in the config toml) to stop early.
195 coverage: pass to pytest()
196 badge: pass to pytest()
198 `def all()` is not allowed since this overshadows a builtin
199 """
200 config = state.update_config(
201 directory=directory,
202 stop_after_first_failure=stop_after_first_failure,
203 coverage=coverage,
204 badge=badge,
205 )
207 ignored_exit_codes = set()
208 if ignore_uninstalled:
209 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND)
211 tools = [ruff, black, mypy, bandit, isort, pydocstyle, pytest]
213 tools = config.determine_which_to_run(tools) + config.determine_plugins_to_run("add_to_all")
215 exit_codes = []
216 for tool in tools:
217 a = [directory]
218 kw = dict(_suppress=True, _ignore=ignored_exit_codes)
220 if tool is pytest: # pragma: no cover
221 kw["coverage"] = config.coverage
222 kw["badge"] = config.badge
224 result = tool(*a, **kw)
225 exit_codes.append(result)
226 if config.stop_after_first_failure and result != 0:
227 break
229 if state.output_format == "json":
230 dump_tools_with_results(tools, exit_codes)
232 return any(exit_codes)
235@app.command()
236@with_exit_code()
237def pytest(
238 directory: T_directory = None,
239 html: bool = False,
240 json: bool = False,
241 coverage: int = None,
242 badge: bool = None,
243 k: typing.Annotated[str, typer.Option("-k")] = None, # fw to pytest
244 s: typing.Annotated[bool, typer.Option("-s")] = False, # fw to pytest
245 v: typing.Annotated[bool, typer.Option("-v")] = False, # fw to pytest
246 x: typing.Annotated[bool, typer.Option("-x")] = False, # fw to pytest
247) -> int: # pragma: no cover
248 """
249 Runs all pytests.
251 Args:
252 directory: where to run pytests on (default is current dir)
253 html: generate HTML coverage output?
254 json: generate JSON coverage output?
255 coverage: threshold for coverage (in %)
256 badge: generate coverage badge (svg)? If you want to change the name, do this in pyproject.toml
258 k: pytest -k <str> option (run specific tests)
259 s: pytest -s option (show output)
260 v: pytest -v option (verbose)
261 x: pytest -x option (stop after first failure)
263 Example:
264 > su6 pytest --coverage 50
265 if any checks fail: exit 1 and red circle
266 if all checks pass but coverage is less than 50%: exit 1, green circle for pytest and red for coverage
267 if all check pass and coverage is at least 50%: exit 0, green circle for pytest and green for coverage
269 if --coverage is not passed, there will be no circle for coverage.
270 """
271 config = state.update_config(directory=directory, coverage=coverage, badge=badge)
273 if config.badge and config.coverage is None:
274 # not None but still check cov
275 config.coverage = 0
277 args = ["--cov", config.directory]
279 if config.coverage is not None:
280 # json output required!
281 json = True
283 if k:
284 args.extend(["-k", k])
285 if s:
286 args.append("-s")
287 if v:
288 args.append("-v")
289 if x:
290 args.append("-x")
292 if html:
293 args.extend(["--cov-report", "html"])
295 if json:
296 args.extend(["--cov-report", "json"])
298 exit_code = run_tool("pytest", *args)
300 if config.coverage is not None:
301 with open("coverage.json") as f:
302 data = json_load(f)
303 percent_covered = math.floor(data["totals"]["percent_covered"])
305 # if actual coverage is less than the the threshold, exit code should be success (0)
306 exit_code = percent_covered < config.coverage
307 circle = RED_CIRCLE if exit_code else GREEN_CIRCLE
308 if state.output_format == "text":
309 print(circle, "coverage")
311 if config.badge:
312 if not isinstance(config.badge, str):
313 # it's still True for some reason?
314 config.badge = DEFAULT_BADGE
316 with contextlib.suppress(FileNotFoundError):
317 os.remove(config.badge)
319 result = local["coverage-badge"]("-o", config.badge)
320 if state.verbosity > 2:
321 info(result)
323 return exit_code
326@app.command(name="fix")
327@with_exit_code()
328def do_fix(directory: T_directory = None, ignore_uninstalled: bool = False) -> bool:
329 """
330 Do everything that's safe to fix (not ruff because that may break semantics).
332 Args:
333 directory: where to run the tools on (default is current dir)
334 ignore_uninstalled: use --ignore-uninstalled to skip exit code 127 (command not found)
336 `def fix()` is not recommended because other commands have 'fix' as an argument so those names would collide.
337 """
338 config = state.update_config(directory=directory)
340 ignored_exit_codes = set()
341 if ignore_uninstalled:
342 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND)
344 tools = [isort, black]
346 tools = config.determine_which_to_run(tools) + config.determine_plugins_to_run("add_to_fix")
348 exit_codes = [tool(directory, fix=True, _suppress=True, _ignore=ignored_exit_codes) for tool in tools]
350 if state.output_format == "json":
351 dump_tools_with_results(tools, exit_codes)
353 return any(exit_codes)
356@app.command()
357@with_exit_code()
358def plugins() -> None: # pragma: nocover
359 """
360 List installed plugin modules.
362 """
363 modules = entry_points(group="su6")
364 match state.output_format:
365 case "text":
366 if modules:
367 print("Installed Plugins:")
368 [print("-", _) for _ in modules]
369 else:
370 print("No Installed Plugins.")
371 case "json":
372 print_json(
373 {
374 _.name: {
375 "name": _.name,
376 "value": _.value,
377 "group": _.group,
378 }
379 for _ in modules
380 }
381 )
384def _pip() -> LocalCommand:
385 """
386 Return a `pip` command.
387 """
388 python = sys.executable
389 return local[python]["-m", "pip"]
392@app.command()
393@with_exit_code()
394def self_update(version: str = None) -> int:
395 """
396 Update `su6` to the latest (stable) version.
398 Args:
399 version: (optional) specific version to update to
400 """
401 pip = _pip()
403 try:
404 pkg = "su6"
405 if version:
406 pkg = f"{pkg}=={version}"
408 args = ["install", "--upgrade", pkg]
409 if state.verbosity >= 3:
410 log_command(pip, args)
412 output = pip(*args)
413 if state.verbosity > 2:
414 info(output)
415 match state.output_format:
416 case "text":
417 print(GREEN_CIRCLE, "self-update")
418 # case json handled automatically by with_exit_code
419 return 0
420 except PlumbumError as e:
421 if state.verbosity > 3:
422 raise e
423 elif state.verbosity > 2:
424 warn(str(e))
425 match state.output_format:
426 case "text":
427 print(RED_CIRCLE, "self-update")
428 # case json handled automatically by with_exit_code
429 return 1
432def version_callback() -> Never:
433 """
434 --version requested!
435 """
436 match state.output_format:
437 case "text":
438 print(f"su6 Version: {__version__}")
439 case "json":
440 print_json({"version": __version__})
441 raise typer.Exit(0)
444def show_config_callback() -> Never:
445 """
446 --show-config requested!
447 """
448 match state.output_format:
449 case "text":
450 print(state)
451 case "json":
452 print_json(asdict(state))
453 raise typer.Exit(0)
456@app.callback(invoke_without_command=True)
457def main(
458 ctx: typer.Context,
459 config: str = None,
460 verbosity: Verbosity = DEFAULT_VERBOSITY,
461 output_format: typing.Annotated[Format, typer.Option("--format")] = DEFAULT_FORMAT,
462 # stops the program:
463 show_config: bool = False,
464 version: bool = False,
465) -> None:
466 """
467 This callback will run before every command, setting the right global flags.
469 Args:
470 ctx: context to determine if a subcommand is passed, etc
471 config: path to a different config toml file
472 verbosity: level of detail to print out (1 - 3)
473 output_format: output format
475 show_config: display current configuration?
476 version: display current version?
478 """
479 if state.config:
480 # if a config already exists, it's outdated, so we clear it.
481 # we don't clear everything since Plugin configs may be already cached.
482 Singleton.clear(state.config)
484 state.load_config(config_file=config, verbosity=verbosity, output_format=output_format)
486 if show_config:
487 show_config_callback()
488 elif version:
489 version_callback()
490 elif not ctx.invoked_subcommand:
491 warn("Missing subcommand. Try `su6 --help` for more info.")
492 # else: just continue
495if __name__ == "__main__": # pragma: no cover
496 app()