Coverage for src/su6/cli.py: 100%
173 statements
« prev ^ index » next coverage.py v7.2.7, created at 2024-03-20 17:04 +0100
« prev ^ index » next coverage.py v7.2.7, created at 2024-03-20 17:04 +0100
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 GREEN_CIRCLE,
24 RED_CIRCLE,
25 YELLOW_CIRCLE,
26 ExitCodes,
27 Format,
28 PlumbumError,
29 Verbosity,
30 dump_tools_with_results,
31 info,
32 is_installed,
33 log_command,
34 print_json,
35 run_tool,
36 state,
37 warn,
38 with_exit_code,
39)
40from .plugins import include_plugins
42app = typer.Typer()
44include_plugins(app)
46# 'directory' is an optional cli argument to many commands, so we define the type here for reuse:
47T_directory: typing.TypeAlias = typing.Annotated[str, typer.Argument()]
50@app.command()
51@with_exit_code()
52def ruff(directory: T_directory = None) -> int:
53 """
54 Runs the Ruff Linter.
56 Args:
57 directory: where to run ruff on (default is current dir)
59 """
60 config = state.update_config(directory=directory)
61 return run_tool("ruff", config.directory)
64@app.command()
65@with_exit_code()
66def black(directory: T_directory = None, fix: bool = False) -> int:
67 """
68 Runs the Black code formatter.
70 Args:
71 directory: where to run black on (default is current dir)
72 fix: if --fix is passed, black will be used to reformat the file(s).
74 """
75 config = state.update_config(directory=directory)
77 args = [config.directory, r"--exclude=venv.+|.+\.bak"]
78 if not fix:
79 args.append("--check")
80 elif state.verbosity > 2:
81 info("note: running WITHOUT --check -> changing files")
83 return run_tool("black", *args)
86@app.command()
87@with_exit_code()
88def isort(directory: T_directory = None, fix: bool = False) -> int:
89 """
90 Runs the import sort (isort) utility.
92 Args:
93 directory: where to run isort on (default is current dir)
94 fix: if --fix is passed, isort will be used to rearrange imports.
96 """
97 config = state.update_config(directory=directory)
98 args = [config.directory]
99 if not fix:
100 args.append("--check-only")
101 elif state.verbosity > 2:
102 info("note: running WITHOUT --check -> changing files")
104 return run_tool("isort", *args)
107@app.command()
108@with_exit_code()
109def mypy(directory: T_directory = None) -> int:
110 """
111 Runs the mypy static type checker.
113 Args:
114 directory: where to run mypy on (default is current dir)
116 """
117 config = state.update_config(directory=directory)
119 return run_tool("mypy", config.directory)
122@app.command()
123@with_exit_code()
124def bandit(directory: T_directory = None) -> int:
125 """
126 Runs the bandit security checker.
128 Args:
129 directory: where to run bandit on (default is current dir)
131 """
132 config = state.update_config(directory=directory)
133 return run_tool("bandit", "-r", "-c", config.pyproject, config.directory)
136@app.command()
137@with_exit_code()
138def pydocstyle(directory: T_directory = None, convention: str = None) -> int:
139 """
140 Runs the pydocstyle docstring checker.
142 Args:
143 directory: where to run pydocstyle on (default is current dir)
144 convention: pep257, numpy, google.
145 """
146 config = state.update_config(directory=directory, docstyle_convention=convention)
148 args = [config.directory]
150 if config.docstyle_convention:
151 args.extend(["--convention", config.docstyle_convention])
153 return run_tool("pydocstyle", *args)
156@app.command(name="list")
157@with_exit_code()
158def list_tools() -> None:
159 """
160 List tools that would run with 'su6 all'.
161 """
162 config = state.update_config()
163 all_tools = [ruff, black, mypy, bandit, isort, pydocstyle, pytest]
164 all_plugin_tools = [_.wrapped for _ in state._registered_plugins.values() if _.what == "command"]
165 tools_to_run = config.determine_which_to_run(all_tools) + config.determine_plugins_to_run("add_to_all")
167 output = {}
168 for tool in all_tools + all_plugin_tools:
169 tool_name = tool.__name__.replace("_", "-")
171 if state.output_format == "text":
172 if tool not in tools_to_run:
173 print(RED_CIRCLE, tool_name)
174 elif not is_installed(tool_name): # pragma: no cover
175 print(YELLOW_CIRCLE, tool_name)
176 else:
177 # tool in tools_to_run
178 print(GREEN_CIRCLE, tool_name)
180 elif state.output_format == "json":
181 output[tool_name] = tool in tools_to_run
183 if state.output_format == "json":
184 print_json(output)
187@app.command(name="all")
188@with_exit_code()
189def check_all(
190 directory: T_directory = None,
191 ignore_uninstalled: bool = False,
192 stop_after_first_failure: bool = None,
193 exclude: list[str] = None,
194 # pytest:
195 coverage: float = None,
196 badge: bool = None,
197) -> bool:
198 """
199 Run all available checks.
201 Args:
202 directory: where to run the tools on (default is current dir)
203 ignore_uninstalled: use --ignore-uninstalled to skip exit code 127 (command not found)
204 stop_after_first_failure: by default, the tool continues to run each test.
205 But if you only want to know if everything passes,
206 you could set this flag (or in the config toml) to stop early.
208 exclude: choose extra services (in addition to config) to skip for this run.
209 coverage: pass to pytest()
210 badge: pass to pytest()
212 `def all()` is not allowed since this overshadows a builtin
213 """
214 config = state.update_config(
215 directory=directory,
216 stop_after_first_failure=stop_after_first_failure,
217 coverage=coverage,
218 badge=badge,
219 )
221 ignored_exit_codes = set()
222 if ignore_uninstalled:
223 ignored_exit_codes.add(ExitCodes.command_not_found)
225 tools = [ruff, black, mypy, bandit, isort, pydocstyle, pytest]
227 tools = config.determine_which_to_run(tools, exclude) + config.determine_plugins_to_run("add_to_all", exclude)
229 exit_codes = []
230 for tool in tools:
231 a = [directory]
232 kw = dict(_suppress=True, _ignore=ignored_exit_codes)
234 if tool is pytest: # pragma: no cover
235 kw["coverage"] = config.coverage
236 kw["badge"] = config.badge
238 result = tool(*a, **kw)
239 exit_codes.append(result)
240 if config.stop_after_first_failure and result != 0:
241 break
243 if state.output_format == "json":
244 dump_tools_with_results(tools, exit_codes)
246 return any(exit_codes)
249@app.command()
250@with_exit_code()
251def pytest(
252 directory: T_directory = None,
253 html: bool = False,
254 json: bool = False,
255 coverage: int = None,
256 badge: bool = None,
257 k: typing.Annotated[str, typer.Option("-k")] = None, # fw to pytest
258 s: typing.Annotated[bool, typer.Option("-s")] = False, # fw to pytest
259 v: typing.Annotated[bool, typer.Option("-v")] = False, # fw to pytest
260 x: typing.Annotated[bool, typer.Option("-x")] = False, # fw to pytest
261) -> int: # pragma: no cover
262 """
263 Runs all pytests.
265 Args:
266 directory: where to run pytests on (default is current dir)
267 html: generate HTML coverage output?
268 json: generate JSON coverage output?
269 coverage: threshold for coverage (in %)
270 badge: generate coverage badge (svg)? If you want to change the name, do this in pyproject.toml
272 k: pytest -k <str> option (run specific tests)
273 s: pytest -s option (show output)
274 v: pytest -v option (verbose)
275 x: pytest -x option (stop after first failure)
277 Example:
278 > su6 pytest --coverage 50
279 if any checks fail: exit 1 and red circle
280 if all checks pass but coverage is less than 50%: exit 1, green circle for pytest and red for coverage
281 if all check pass and coverage is at least 50%: exit 0, green circle for pytest and green for coverage
283 if --coverage is not passed, there will be no circle for coverage.
284 """
285 config = state.update_config(directory=directory, coverage=coverage, badge=badge)
287 if config.badge and config.coverage is None:
288 # not None but still check cov
289 config.coverage = 0
291 args = ["--cov", config.directory]
293 if config.coverage is not None:
294 # json output required!
295 json = True
297 if k:
298 args.extend(["-k", k])
299 if s:
300 args.append("-s")
301 if v:
302 args.append("-v")
303 if x:
304 args.append("-x")
306 if html:
307 args.extend(["--cov-report", "html"])
309 if json:
310 args.extend(["--cov-report", "json"])
312 exit_code = run_tool("pytest", *args)
314 if config.coverage is not None:
315 with open("coverage.json") as f:
316 data = json_load(f)
317 percent_covered = math.floor(data["totals"]["percent_covered"])
319 # if actual coverage is less than the the threshold, exit code should be success (0)
320 exit_code = percent_covered < config.coverage
321 circle = RED_CIRCLE if exit_code else GREEN_CIRCLE
322 if state.output_format == "text":
323 print(circle, "coverage")
325 if config.badge:
326 if not isinstance(config.badge, str):
327 # it's still True for some reason?
328 config.badge = DEFAULT_BADGE
330 with contextlib.suppress(FileNotFoundError):
331 os.remove(config.badge)
333 result = local["coverage-badge"]("-o", config.badge)
334 if state.verbosity > 2:
335 info(result)
337 return exit_code
340@app.command(name="fix")
341@with_exit_code()
342def do_fix(directory: T_directory = None, ignore_uninstalled: bool = False, exclude: list[str] = None) -> bool:
343 """
344 Do everything that's safe to fix (not ruff because that may break semantics).
346 Args:
347 directory: where to run the tools on (default is current dir)
348 ignore_uninstalled: use --ignore-uninstalled to skip exit code 127 (command not found)
349 exclude: choose extra services (in addition to config) to skip for this run.
352 `def fix()` is not recommended because other commands have 'fix' as an argument so those names would collide.
353 """
354 config = state.update_config(directory=directory)
356 ignored_exit_codes = set()
357 if ignore_uninstalled:
358 ignored_exit_codes.add(ExitCodes.command_not_found)
360 tools = [isort, black]
362 tools = config.determine_which_to_run(tools, exclude) + config.determine_plugins_to_run("add_to_fix", exclude)
364 exit_codes = [tool(directory, fix=True, _suppress=True, _ignore=ignored_exit_codes) for tool in tools]
366 if state.output_format == "json":
367 dump_tools_with_results(tools, exit_codes)
369 return any(exit_codes)
372@app.command()
373@with_exit_code()
374def plugins() -> None: # pragma: nocover
375 """
376 List installed plugin modules.
378 """
379 modules = entry_points(group="su6")
380 match state.output_format:
381 case "text":
382 if modules:
383 print("Installed Plugins:")
384 [print("-", _) for _ in modules]
385 else:
386 print("No Installed Plugins.")
387 case "json":
388 print_json(
389 {
390 _.name: {
391 "name": _.name,
392 "value": _.value,
393 "group": _.group,
394 }
395 for _ in modules
396 }
397 )
400def _pip() -> LocalCommand:
401 """
402 Return a `pip` command.
403 """
404 python = sys.executable
405 return local[python]["-m", "pip"]
408@app.command()
409@with_exit_code()
410def self_update(version: str = None) -> int:
411 """
412 Update `su6` to the latest (stable) version.
414 Args:
415 version: (optional) specific version to update to
416 """
417 pip = _pip()
419 try:
420 pkg = "su6"
421 if version:
422 pkg = f"{pkg}=={version}"
424 args = ["install", "--upgrade", pkg]
425 if state.verbosity >= 3:
426 log_command(pip, args)
428 output = pip(*args)
429 if state.verbosity > 2:
430 info(output)
431 match state.output_format:
432 case "text":
433 print(GREEN_CIRCLE, "self-update")
434 # case json handled automatically by with_exit_code
435 return 0
436 except PlumbumError as e:
437 if state.verbosity > 3:
438 raise e
439 elif state.verbosity > 2:
440 warn(str(e))
441 match state.output_format:
442 case "text":
443 print(RED_CIRCLE, "self-update")
444 # case json handled automatically by with_exit_code
445 return 1
448def version_callback() -> Never:
449 """
450 --version requested!
451 """
452 match state.output_format:
453 case "text":
454 print(f"su6 Version: {__version__}")
455 case "json":
456 print_json({"version": __version__})
457 raise typer.Exit(0)
460def show_config_callback() -> Never:
461 """
462 --show-config requested!
463 """
464 match state.output_format:
465 case "text":
466 print(state)
467 case "json":
468 print_json(asdict(state))
469 raise typer.Exit(0)
472@app.callback(invoke_without_command=True)
473def main(
474 ctx: typer.Context,
475 config: str = None,
476 verbosity: Verbosity = DEFAULT_VERBOSITY,
477 output_format: typing.Annotated[Format, typer.Option("--format")] = DEFAULT_FORMAT,
478 # stops the program:
479 show_config: bool = False,
480 version: bool = False,
481) -> None:
482 """
483 This callback will run before every command, setting the right global flags.
485 Args:
486 ctx: context to determine if a subcommand is passed, etc
487 config: path to a different config toml file
488 verbosity: level of detail to print out (1 - 3)
489 output_format: output format
491 show_config: display current configuration?
492 version: display current version?
494 """
495 if state.config:
496 # if a config already exists, it's outdated, so we clear it.
497 # we don't clear everything since Plugin configs may be already cached.
498 Singleton.clear(state.config)
500 state.load_config(config_file=config, verbosity=verbosity, output_format=output_format)
502 if show_config:
503 show_config_callback()
504 elif version:
505 version_callback()
506 elif not ctx.invoked_subcommand:
507 warn("Missing subcommand. Try `su6 --help` for more info.")
508 # else: just continue
511if __name__ == "__main__": # pragma: no cover
512 app()