Coverage for src/su6/cli.py: 100%
170 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-10-09 14:16 +0200
« prev ^ index » next coverage.py v7.2.7, created at 2023-10-09 14:16 +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)
117 return run_tool("mypy", config.directory)
120@app.command()
121@with_exit_code()
122def bandit(directory: T_directory = None) -> int:
123 """
124 Runs the bandit security checker.
126 Args:
127 directory: where to run bandit on (default is current dir)
129 """
130 config = state.update_config(directory=directory)
131 return run_tool("bandit", "-r", "-c", config.pyproject, config.directory)
134@app.command()
135@with_exit_code()
136def pydocstyle(directory: T_directory = None) -> int:
137 """
138 Runs the pydocstyle docstring checker.
140 Args:
141 directory: where to run pydocstyle on (default is current dir)
143 """
144 config = state.update_config(directory=directory)
145 return run_tool("pydocstyle", config.directory)
148@app.command(name="list")
149@with_exit_code()
150def list_tools() -> None:
151 """
152 List tools that would run with 'su6 all'.
153 """
154 config = state.update_config()
155 all_tools = [ruff, black, mypy, bandit, isort, pydocstyle, pytest]
156 all_plugin_tools = [_.wrapped for _ in state._registered_plugins.values() if _.what == "command"]
157 tools_to_run = config.determine_which_to_run(all_tools) + config.determine_plugins_to_run("add_to_all")
159 output = {}
160 for tool in all_tools + all_plugin_tools:
161 tool_name = tool.__name__.replace("_", "-")
163 if state.output_format == "text":
164 if tool in tools_to_run:
165 print(GREEN_CIRCLE, tool_name)
166 else:
167 print(RED_CIRCLE, tool_name)
169 elif state.output_format == "json":
170 output[tool_name] = tool in tools_to_run
172 if state.output_format == "json":
173 print_json(output)
176@app.command(name="all")
177@with_exit_code()
178def check_all(
179 directory: T_directory = None,
180 ignore_uninstalled: bool = False,
181 stop_after_first_failure: bool = None,
182 exclude: list[str] = None,
183 # pytest:
184 coverage: float = None,
185 badge: bool = None,
186) -> bool:
187 """
188 Run all available checks.
190 Args:
191 directory: where to run the tools on (default is current dir)
192 ignore_uninstalled: use --ignore-uninstalled to skip exit code 127 (command not found)
193 stop_after_first_failure: by default, the tool continues to run each test.
194 But if you only want to know if everything passes,
195 you could set this flag (or in the config toml) to stop early.
197 exclude: choose extra services (in addition to config) to skip for this run.
198 coverage: pass to pytest()
199 badge: pass to pytest()
201 `def all()` is not allowed since this overshadows a builtin
202 """
203 config = state.update_config(
204 directory=directory,
205 stop_after_first_failure=stop_after_first_failure,
206 coverage=coverage,
207 badge=badge,
208 )
210 ignored_exit_codes = set()
211 if ignore_uninstalled:
212 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND)
214 tools = [ruff, black, mypy, bandit, isort, pydocstyle, pytest]
216 tools = config.determine_which_to_run(tools, exclude) + config.determine_plugins_to_run("add_to_all", exclude)
218 exit_codes = []
219 for tool in tools:
220 a = [directory]
221 kw = dict(_suppress=True, _ignore=ignored_exit_codes)
223 if tool is pytest: # pragma: no cover
224 kw["coverage"] = config.coverage
225 kw["badge"] = config.badge
227 result = tool(*a, **kw)
228 exit_codes.append(result)
229 if config.stop_after_first_failure and result != 0:
230 break
232 if state.output_format == "json":
233 dump_tools_with_results(tools, exit_codes)
235 return any(exit_codes)
238@app.command()
239@with_exit_code()
240def pytest(
241 directory: T_directory = None,
242 html: bool = False,
243 json: bool = False,
244 coverage: int = None,
245 badge: bool = None,
246 k: typing.Annotated[str, typer.Option("-k")] = None, # fw to pytest
247 s: typing.Annotated[bool, typer.Option("-s")] = False, # fw to pytest
248 v: typing.Annotated[bool, typer.Option("-v")] = False, # fw to pytest
249 x: typing.Annotated[bool, typer.Option("-x")] = False, # fw to pytest
250) -> int: # pragma: no cover
251 """
252 Runs all pytests.
254 Args:
255 directory: where to run pytests on (default is current dir)
256 html: generate HTML coverage output?
257 json: generate JSON coverage output?
258 coverage: threshold for coverage (in %)
259 badge: generate coverage badge (svg)? If you want to change the name, do this in pyproject.toml
261 k: pytest -k <str> option (run specific tests)
262 s: pytest -s option (show output)
263 v: pytest -v option (verbose)
264 x: pytest -x option (stop after first failure)
266 Example:
267 > su6 pytest --coverage 50
268 if any checks fail: exit 1 and red circle
269 if all checks pass but coverage is less than 50%: exit 1, green circle for pytest and red for coverage
270 if all check pass and coverage is at least 50%: exit 0, green circle for pytest and green for coverage
272 if --coverage is not passed, there will be no circle for coverage.
273 """
274 config = state.update_config(directory=directory, coverage=coverage, badge=badge)
276 if config.badge and config.coverage is None:
277 # not None but still check cov
278 config.coverage = 0
280 args = ["--cov", config.directory]
282 if config.coverage is not None:
283 # json output required!
284 json = True
286 if k:
287 args.extend(["-k", k])
288 if s:
289 args.append("-s")
290 if v:
291 args.append("-v")
292 if x:
293 args.append("-x")
295 if html:
296 args.extend(["--cov-report", "html"])
298 if json:
299 args.extend(["--cov-report", "json"])
301 exit_code = run_tool("pytest", *args)
303 if config.coverage is not None:
304 with open("coverage.json") as f:
305 data = json_load(f)
306 percent_covered = math.floor(data["totals"]["percent_covered"])
308 # if actual coverage is less than the the threshold, exit code should be success (0)
309 exit_code = percent_covered < config.coverage
310 circle = RED_CIRCLE if exit_code else GREEN_CIRCLE
311 if state.output_format == "text":
312 print(circle, "coverage")
314 if config.badge:
315 if not isinstance(config.badge, str):
316 # it's still True for some reason?
317 config.badge = DEFAULT_BADGE
319 with contextlib.suppress(FileNotFoundError):
320 os.remove(config.badge)
322 result = local["coverage-badge"]("-o", config.badge)
323 if state.verbosity > 2:
324 info(result)
326 return exit_code
329@app.command(name="fix")
330@with_exit_code()
331def do_fix(directory: T_directory = None, ignore_uninstalled: bool = False, exclude: list[str] = None) -> bool:
332 """
333 Do everything that's safe to fix (not ruff because that may break semantics).
335 Args:
336 directory: where to run the tools on (default is current dir)
337 ignore_uninstalled: use --ignore-uninstalled to skip exit code 127 (command not found)
338 exclude: choose extra services (in addition to config) to skip for this run.
341 `def fix()` is not recommended because other commands have 'fix' as an argument so those names would collide.
342 """
343 config = state.update_config(directory=directory)
345 ignored_exit_codes = set()
346 if ignore_uninstalled:
347 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND)
349 tools = [isort, black]
351 tools = config.determine_which_to_run(tools, exclude) + config.determine_plugins_to_run("add_to_fix", exclude)
353 exit_codes = [tool(directory, fix=True, _suppress=True, _ignore=ignored_exit_codes) for tool in tools]
355 if state.output_format == "json":
356 dump_tools_with_results(tools, exit_codes)
358 return any(exit_codes)
361@app.command()
362@with_exit_code()
363def plugins() -> None: # pragma: nocover
364 """
365 List installed plugin modules.
367 """
368 modules = entry_points(group="su6")
369 match state.output_format:
370 case "text":
371 if modules:
372 print("Installed Plugins:")
373 [print("-", _) for _ in modules]
374 else:
375 print("No Installed Plugins.")
376 case "json":
377 print_json(
378 {
379 _.name: {
380 "name": _.name,
381 "value": _.value,
382 "group": _.group,
383 }
384 for _ in modules
385 }
386 )
389def _pip() -> LocalCommand:
390 """
391 Return a `pip` command.
392 """
393 python = sys.executable
394 return local[python]["-m", "pip"]
397@app.command()
398@with_exit_code()
399def self_update(version: str = None) -> int:
400 """
401 Update `su6` to the latest (stable) version.
403 Args:
404 version: (optional) specific version to update to
405 """
406 pip = _pip()
408 try:
409 pkg = "su6"
410 if version:
411 pkg = f"{pkg}=={version}"
413 args = ["install", "--upgrade", pkg]
414 if state.verbosity >= 3:
415 log_command(pip, args)
417 output = pip(*args)
418 if state.verbosity > 2:
419 info(output)
420 match state.output_format:
421 case "text":
422 print(GREEN_CIRCLE, "self-update")
423 # case json handled automatically by with_exit_code
424 return 0
425 except PlumbumError as e:
426 if state.verbosity > 3:
427 raise e
428 elif state.verbosity > 2:
429 warn(str(e))
430 match state.output_format:
431 case "text":
432 print(RED_CIRCLE, "self-update")
433 # case json handled automatically by with_exit_code
434 return 1
437def version_callback() -> Never:
438 """
439 --version requested!
440 """
441 match state.output_format:
442 case "text":
443 print(f"su6 Version: {__version__}")
444 case "json":
445 print_json({"version": __version__})
446 raise typer.Exit(0)
449def show_config_callback() -> Never:
450 """
451 --show-config requested!
452 """
453 match state.output_format:
454 case "text":
455 print(state)
456 case "json":
457 print_json(asdict(state))
458 raise typer.Exit(0)
461@app.callback(invoke_without_command=True)
462def main(
463 ctx: typer.Context,
464 config: str = None,
465 verbosity: Verbosity = DEFAULT_VERBOSITY,
466 output_format: typing.Annotated[Format, typer.Option("--format")] = DEFAULT_FORMAT,
467 # stops the program:
468 show_config: bool = False,
469 version: bool = False,
470) -> None:
471 """
472 This callback will run before every command, setting the right global flags.
474 Args:
475 ctx: context to determine if a subcommand is passed, etc
476 config: path to a different config toml file
477 verbosity: level of detail to print out (1 - 3)
478 output_format: output format
480 show_config: display current configuration?
481 version: display current version?
483 """
484 if state.config:
485 # if a config already exists, it's outdated, so we clear it.
486 # we don't clear everything since Plugin configs may be already cached.
487 Singleton.clear(state.config)
489 state.load_config(config_file=config, verbosity=verbosity, output_format=output_format)
491 if show_config:
492 show_config_callback()
493 elif version:
494 version_callback()
495 elif not ctx.invoked_subcommand:
496 warn("Missing subcommand. Try `su6 --help` for more info.")
497 # else: just continue
500if __name__ == "__main__": # pragma: no cover
501 app()