Coverage for src/su6/cli.py: 100%
156 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-06 22:26 +0200
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-06 22:26 +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) -> int: # pragma: no cover
215 """
216 Runs all pytests.
218 Args:
219 directory: where to run pytests on (default is current dir)
220 html: generate HTML coverage output?
221 json: generate JSON coverage output?
222 coverage: threshold for coverage (in %)
223 badge: generate coverage badge (svg)? If you want to change the name, do this in pyproject.toml
225 k: pytest -k <str> option
226 s: pytest -s option
228 Example:
229 > su6 pytest --coverage 50
230 if any checks fail: exit 1 and red circle
231 if all checks pass but coverage is less than 50%: exit 1, green circle for pytest and red for coverage
232 if all check pass and coverage is at least 50%: exit 0, green circle for pytest and green for coverage
234 if --coverage is not passed, there will be no circle for coverage.
235 """
236 config = state.update_config(directory=directory, coverage=coverage, badge=badge)
238 if config.badge and config.coverage is None:
239 # not None but still check cov
240 config.coverage = 0
242 args = ["--cov", config.directory]
244 if config.coverage is not None:
245 # json output required!
246 json = True
248 if k:
249 args.extend(["-k", k])
250 if s:
251 args.append("-s")
253 if html:
254 args.extend(["--cov-report", "html"])
256 if json:
257 args.extend(["--cov-report", "json"])
259 exit_code = run_tool("pytest", *args)
261 if config.coverage is not None:
262 with open("coverage.json") as f:
263 data = json_load(f)
264 percent_covered = math.floor(data["totals"]["percent_covered"])
266 # if actual coverage is less than the the threshold, exit code should be success (0)
267 exit_code = percent_covered < config.coverage
268 circle = RED_CIRCLE if exit_code else GREEN_CIRCLE
269 if state.output_format == "text":
270 print(circle, "coverage")
272 if config.badge:
273 if not isinstance(config.badge, str):
274 # it's still True for some reason?
275 config.badge = DEFAULT_BADGE
277 with contextlib.suppress(FileNotFoundError):
278 os.remove(config.badge)
280 result = local["coverage-badge"]("-o", config.badge)
281 if state.verbosity > 2:
282 info(result)
284 return exit_code
287@app.command(name="fix")
288@with_exit_code()
289def do_fix(directory: T_directory = None, ignore_uninstalled: bool = False) -> bool:
290 """
291 Do everything that's safe to fix (not ruff because that may break semantics).
293 Args:
294 directory: where to run the tools on (default is current dir)
295 ignore_uninstalled: use --ignore-uninstalled to skip exit code 127 (command not found)
297 `def fix()` is not recommended because other commands have 'fix' as an argument so those names would collide.
298 """
299 config = state.update_config(directory=directory)
301 ignored_exit_codes = set()
302 if ignore_uninstalled:
303 ignored_exit_codes.add(EXIT_CODE_COMMAND_NOT_FOUND)
305 tools = config.determine_which_to_run([black, isort])
307 exit_codes = [tool(directory, fix=True, _suppress=True, _ignore=ignored_exit_codes) for tool in tools]
309 if state.output_format == "json":
310 dump_tools_with_results(tools, exit_codes)
312 return any(exit_codes)
315@app.command()
316@with_exit_code()
317def plugins() -> None:
318 """
319 List installed plugin modules.
321 """
322 modules = entry_points(group="su6")
323 match state.output_format:
324 case "text":
325 if modules:
326 print("Installed Plugins:")
327 [print("-", _) for _ in modules]
328 else: # pragma: nocover
329 print("No Installed Plugins.")
330 case "json":
331 print_json(
332 {
333 _.name: {
334 "name": _.name,
335 "value": _.value,
336 "group": _.group,
337 }
338 for _ in modules
339 }
340 )
343def _pip() -> LocalCommand:
344 """
345 Return a `pip` command.
346 """
347 python = sys.executable
348 return local[python]["-m", "pip"]
351@app.command()
352@with_exit_code()
353def self_update(version: str = None) -> int:
354 """
355 Update `su6` to the latest (stable) version.
357 Args:
358 version: (optional) specific version to update to
359 """
360 pip = _pip()
362 try:
363 pkg = "su6"
364 if version:
365 pkg = f"{pkg}=={version}"
367 args = ["install", "--upgrade", pkg]
368 if state.verbosity >= 3:
369 log_command(pip, args)
371 output = pip(*args)
372 if state.verbosity > 2:
373 info(output)
374 match state.output_format:
375 case "text":
376 print(GREEN_CIRCLE, "self-update")
377 # case json handled automatically by with_exit_code
378 return 0
379 except PlumbumError as e:
380 if state.verbosity > 3:
381 raise e
382 elif state.verbosity > 2:
383 warn(str(e))
384 match state.output_format:
385 case "text":
386 print(RED_CIRCLE, "self-update")
387 # case json handled automatically by with_exit_code
388 return 1
391def version_callback() -> typing.Never:
392 """
393 --version requested!
394 """
395 match state.output_format:
396 case "text":
397 print(f"su6 Version: {__version__}")
398 case "json":
399 print_json({"version": __version__})
400 raise typer.Exit(0)
403def show_config_callback() -> typing.Never:
404 """
405 --show-config requested!
406 """
407 match state.output_format:
408 case "text":
409 print(state)
410 case "json":
411 print_json(asdict(state))
412 raise typer.Exit(0)
415@app.callback(invoke_without_command=True)
416def main(
417 ctx: typer.Context,
418 config: str = None,
419 verbosity: Verbosity = DEFAULT_VERBOSITY,
420 output_format: typing.Annotated[Format, typer.Option("--format")] = DEFAULT_FORMAT,
421 # stops the program:
422 show_config: bool = False,
423 version: bool = False,
424) -> None:
425 """
426 This callback will run before every command, setting the right global flags.
428 Args:
429 ctx: context to determine if a subcommand is passed, etc
430 config: path to a different config toml file
431 verbosity: level of detail to print out (1 - 3)
432 output_format: output format
434 show_config: display current configuration?
435 version: display current version?
437 """
438 Singleton.clear()
440 state.load_config(config_file=config, verbosity=verbosity, output_format=output_format)
442 if show_config:
443 show_config_callback()
444 elif version:
445 version_callback()
446 elif not ctx.invoked_subcommand:
447 warn("Missing subcommand. Try `su6 --help` for more info.")
448 # else: just continue
451if __name__ == "__main__": # pragma: no cover
452 app()