Coverage for src/su6/cli.py: 100%
169 statements
« prev ^ index » next coverage.py v7.8.1, created at 2025-09-19 22:55 +0200
« prev ^ index » next coverage.py v7.8.1, created at 2025-09-19 22:55 +0200
1"""This file contains all Typer Commands."""
3import contextlib
4import math
5import os
6import sys
7import typing
8from dataclasses import asdict
9from importlib.metadata import entry_points
10from json import load as json_load
12import typer
13from configuraptor import Singleton
14from plumbum import local
15from plumbum.machines import LocalCommand
16from rich import print
17from typing_extensions import Never
19from .__about__ import __version__
20from .core import (
21 DEFAULT_BADGE,
22 DEFAULT_FORMAT,
23 DEFAULT_VERBOSITY,
24 GREEN_CIRCLE,
25 RED_CIRCLE,
26 YELLOW_CIRCLE,
27 ExitCodes,
28 Format,
29 PlumbumError,
30 Verbosity,
31 dump_tools_with_results,
32 info,
33 is_installed,
34 log_command,
35 print_json,
36 run_tool,
37 state,
38 warn,
39 with_exit_code,
40)
41from .plugins import include_plugins
43app = typer.Typer()
45include_plugins(app)
47# 'directory' is an optional cli argument to many commands, so we define the type here for reuse:
48T_directory: typing.TypeAlias = typing.Annotated[str, typer.Argument()]
51@app.command()
52@with_exit_code()
53def ruff(directory: T_directory = None) -> int:
54 """
55 Runs the Ruff Linter.
57 Args:
58 directory: where to run ruff on (default is current dir)
60 """
61 config = state.update_config(directory=directory)
63 return run_tool("ruff", "check", config.directory)
66@app.command()
67@with_exit_code()
68def black(directory: T_directory = None, fix: bool = False) -> int:
69 """
70 Runs the Black code formatter.
72 Args:
73 directory: where to run black on (default is current dir)
74 fix: if --fix is passed, black will be used to reformat the file(s).
76 """
77 config = state.update_config(directory=directory)
79 args = [config.directory, r"--exclude=venv.+|.+\.bak"]
80 if not fix:
81 args.append("--check")
82 elif state.verbosity > 2:
83 info("note: running WITHOUT --check -> changing files")
85 return run_tool("black", *args)
88@app.command()
89@with_exit_code()
90def isort(directory: T_directory = None, fix: bool = False) -> int:
91 """
92 Runs the import sort (isort) utility.
94 Args:
95 directory: where to run isort on (default is current dir)
96 fix: if --fix is passed, isort will be used to rearrange imports.
98 """
99 config = state.update_config(directory=directory)
100 args = [config.directory]
101 if not fix:
102 args.append("--check-only")
103 elif state.verbosity > 2:
104 info("note: running WITHOUT --check -> changing files")
106 return run_tool("isort", *args)
109@app.command()
110@with_exit_code()
111def mypy(directory: T_directory = None) -> int:
112 """
113 Runs the mypy static type checker.
115 Args:
116 directory: where to run mypy on (default is current dir)
118 """
119 config = state.update_config(directory=directory)
121 return run_tool("mypy", config.directory)
124@app.command()
125@with_exit_code()
126def bandit(directory: T_directory = None) -> int:
127 """
128 Runs the bandit security checker.
130 Args:
131 directory: where to run bandit on (default is current dir)
133 """
134 config = state.update_config(directory=directory)
135 return run_tool("bandit", "-r", "-c", config.pyproject, config.directory)
138@app.command()
139@with_exit_code()
140def pydocstyle(directory: T_directory = None, convention: str = None) -> int:
141 """
142 Runs the pydocstyle docstring checker.
144 Args:
145 directory: where to run pydocstyle on (default is current dir)
146 convention: pep257, numpy, google.
147 """
148 config = state.update_config(directory=directory, docstyle_convention=convention)
150 args = [config.directory]
152 if config.docstyle_convention:
153 args.extend(["--convention", config.docstyle_convention])
155 return run_tool("pydocstyle", *args)
158@app.command(name="list")
159@with_exit_code()
160def list_tools() -> None:
161 """
162 List tools that would run with 'su6 all'.
163 """
164 config = state.update_config()
165 all_tools = [ruff, black, mypy, bandit, isort, pydocstyle, pytest]
166 all_plugin_tools = [_.wrapped for _ in state._registered_plugins.values() if _.what == "command"]
167 tools_to_run = config.determine_which_to_run(all_tools) + config.determine_plugins_to_run("add_to_all")
169 output = {}
170 for tool in all_tools + all_plugin_tools:
171 tool_name = tool.__name__.replace("_", "-")
173 if state.output_format == "text":
174 if tool not in tools_to_run:
175 print(RED_CIRCLE, tool_name)
176 elif not is_installed(tool_name): # pragma: no cover
177 print(YELLOW_CIRCLE, tool_name)
178 else:
179 # tool in tools_to_run
180 print(GREEN_CIRCLE, tool_name)
182 elif state.output_format == "json":
183 output[tool_name] = tool in tools_to_run
185 if state.output_format == "json":
186 print_json(output)
189@app.command(name="all")
190@with_exit_code()
191def check_all(
192 directory: T_directory = None,
193 ignore_uninstalled: bool = False,
194 stop_after_first_failure: bool = None,
195 exclude: list[str] = None,
196 # pytest:
197 coverage: float = None,
198 badge: bool = None,
199) -> bool:
200 """
201 Run all available checks.
203 Args:
204 directory: where to run the tools on (default is current dir)
205 ignore_uninstalled: use --ignore-uninstalled to skip exit code 127 (command not found)
206 stop_after_first_failure: by default, the tool continues to run each test.
207 But if you only want to know if everything passes,
208 you could set this flag (or in the config toml) to stop early.
210 exclude: choose extra services (in addition to config) to skip for this run.
211 coverage: pass to pytest()
212 badge: pass to pytest()
214 `def all()` is not allowed since this overshadows a builtin
215 """
216 config = state.update_config(
217 directory=directory,
218 stop_after_first_failure=stop_after_first_failure,
219 coverage=coverage,
220 badge=badge,
221 )
223 ignored_exit_codes = set()
224 if ignore_uninstalled:
225 ignored_exit_codes.add(ExitCodes.command_not_found)
227 tools = [ruff, black, mypy, bandit, isort, pydocstyle, pytest]
229 tools = config.determine_which_to_run(tools, exclude) + config.determine_plugins_to_run("add_to_all", exclude)
231 exit_codes = []
232 for tool in tools:
233 a = [directory]
234 kw = dict(_suppress=True, _ignore=ignored_exit_codes)
236 if tool is pytest: # pragma: no cover
237 kw["coverage"] = config.coverage
238 kw["badge"] = config.badge
240 result = tool(*a, **kw)
241 exit_codes.append(result)
242 if config.stop_after_first_failure and result != 0:
243 break
245 if state.output_format == "json":
246 dump_tools_with_results(tools, exit_codes)
248 return any(exit_codes)
251@app.command()
252@with_exit_code()
253def pytest(
254 directory: T_directory = None,
255 html: bool = False,
256 json: bool = False,
257 xml: bool = False,
258 coverage: int = None,
259 badge: bool = None,
260 k: typing.Annotated[str, typer.Option("-k")] = None, # fw to pytest
261 s: typing.Annotated[bool, typer.Option("-s")] = False, # fw to pytest
262 v: typing.Annotated[bool, typer.Option("-v")] = False, # fw to pytest
263 x: typing.Annotated[bool, typer.Option("-x")] = False, # fw to pytest
264) -> int: # pragma: no cover
265 """
266 Runs all pytests.
268 Args:
269 directory: where to run pytests on (default is current dir)
270 html: generate HTML coverage output?
271 json: generate JSON coverage output?
272 xml: generate XML coverage output?
273 coverage: threshold for coverage (in %)
274 badge: generate coverage badge (svg)? If you want to change the name, do this in pyproject.toml
276 k: pytest -k <str> option (run specific tests)
277 s: pytest -s option (show output)
278 v: pytest -v option (verbose)
279 x: pytest -x option (stop after first failure)
281 Example:
282 > su6 pytest --coverage 50
283 if any checks fail: exit 1 and red circle
284 if all checks pass but coverage is less than 50%: exit 1, green circle for pytest and red for coverage
285 if all check pass and coverage is at least 50%: exit 0, green circle for pytest and green for coverage
287 if --coverage is not passed, there will be no circle for coverage.
288 """
289 config = state.update_config(directory=directory, coverage=coverage, badge=badge)
291 if config.badge and config.coverage is None:
292 # not None but still check cov
293 config.coverage = 0
295 args = ["--cov", config.directory]
297 if config.coverage is not None:
298 # json output required!
299 json = True
301 if config.badge is not None:
302 # xml output required!
303 xml = True
305 if k:
306 # -k for specific test
307 args.extend(["-k", k])
308 if s:
309 # -s for showing output
310 args.append("-s")
311 if v:
312 # -v for verbose
313 args.append("-v")
314 if x:
315 # -x for stopping after first failure
316 args.append("-x")
318 if html:
319 args.extend(["--cov-report", "html"])
321 if json:
322 args.extend(["--cov-report", "json"])
324 if xml:
325 args.extend(["--cov-report", "xml"])
327 exit_code = run_tool("pytest", *args)
329 if config.coverage is not None:
330 with open("coverage.json") as f:
331 data = json_load(f)
332 percent_covered = math.floor(data["totals"]["percent_covered"])
334 # if actual coverage is less than the the threshold, exit code should be success (0)
335 exit_code = percent_covered < config.coverage
336 circle = RED_CIRCLE if exit_code else GREEN_CIRCLE
337 if state.output_format == "text":
338 print(circle, "coverage")
340 if config.badge:
341 if not isinstance(config.badge, str):
342 # it's still True for some reason?
343 config.badge = DEFAULT_BADGE
345 with contextlib.suppress(FileNotFoundError):
346 os.remove(config.badge)
348 result = local["genbadge"]("coverage", "-i", "coverage.xml", "-o", config.badge)
349 if state.verbosity > 2:
350 info(result)
352 return exit_code
355@app.command(name="fix")
356@with_exit_code()
357def do_fix(directory: T_directory = None, ignore_uninstalled: bool = False, exclude: list[str] = None) -> bool:
358 """
359 Do everything that's safe to fix (not ruff because that may break semantics).
361 Args:
362 directory: where to run the tools on (default is current dir)
363 ignore_uninstalled: use --ignore-uninstalled to skip exit code 127 (command not found)
364 exclude: choose extra services (in addition to config) to skip for this run.
367 `def fix()` is not recommended because other commands have 'fix' as an argument so those names would collide.
368 """
369 config = state.update_config(directory=directory)
371 ignored_exit_codes = set()
372 if ignore_uninstalled:
373 ignored_exit_codes.add(ExitCodes.command_not_found)
375 tools = [isort, black]
377 tools = config.determine_which_to_run(tools, exclude) + config.determine_plugins_to_run("add_to_fix", exclude)
379 exit_codes = [tool(directory, fix=True, _suppress=True, _ignore=ignored_exit_codes) for tool in tools]
381 if state.output_format == "json":
382 dump_tools_with_results(tools, exit_codes)
384 return any(exit_codes)
387@app.command()
388@with_exit_code()
389def plugins() -> None: # pragma: nocover
390 """
391 List installed plugin modules.
393 """
394 modules = entry_points(group="su6")
395 match state.output_format:
396 case "text":
397 if modules:
398 print("Installed Plugins:")
399 [print("-", _) for _ in modules]
400 else:
401 print("No Installed Plugins.")
402 case "json":
403 print_json(
404 {
405 _.name: {
406 "name": _.name,
407 "value": _.value,
408 "group": _.group,
409 }
410 for _ in modules
411 }
412 )
415def _pip() -> LocalCommand:
416 """
417 Return a `pip` command.
418 """
419 python = sys.executable
420 return local[python]["-m", "pip"]
423@app.command()
424@with_exit_code()
425def self_update(version: str = None) -> int:
426 """
427 Update `su6` to the latest (stable) version.
429 Args:
430 version: (optional) specific version to update to
431 """
432 pip = _pip()
434 try:
435 pkg = "su6"
436 if version:
437 pkg = f"{pkg}=={version}"
439 args = ["install", "--upgrade", pkg]
440 if state.verbosity >= 3:
441 log_command(pip, args)
443 output = pip(*args)
444 if state.verbosity > 2:
445 info(output)
446 match state.output_format:
447 case "text":
448 print(GREEN_CIRCLE, "self-update")
449 # case json handled automatically by with_exit_code
450 return 0
451 except PlumbumError as e:
452 if state.verbosity > 3:
453 raise e
454 elif state.verbosity > 2:
455 warn(str(e))
456 match state.output_format:
457 case "text":
458 print(RED_CIRCLE, "self-update")
459 # case json handled automatically by with_exit_code
460 return 1
463def version_callback() -> Never:
464 """
465 --version requested!
466 """
467 match state.output_format:
468 case "text":
469 print(f"su6 Version: {__version__}")
470 case "json":
471 print_json({"version": __version__})
472 raise typer.Exit(0)
475def show_config_callback() -> Never:
476 """
477 --show-config requested!
478 """
479 match state.output_format:
480 case "text":
481 print(state)
482 case "json":
483 print_json(asdict(state))
484 raise typer.Exit(0)
487@app.callback(invoke_without_command=True)
488def main(
489 ctx: typer.Context,
490 config: str = None,
491 verbosity: Verbosity = DEFAULT_VERBOSITY,
492 output_format: typing.Annotated[Format, typer.Option("--format")] = DEFAULT_FORMAT,
493 # stops the program:
494 show_config: bool = False,
495 version: bool = False,
496) -> None:
497 """
498 This callback will run before every command, setting the right global flags.
500 Args:
501 ctx: context to determine if a subcommand is passed, etc
502 config: path to a different config toml file
503 verbosity: level of detail to print out (1 - 3)
504 output_format: output format
506 show_config: display current configuration?
507 version: display current version?
509 """
510 if state.config:
511 # if a config already exists, it's outdated, so we clear it.
512 # we don't clear everything since Plugin configs may be already cached.
513 Singleton.clear(state.config)
515 state.load_config(config_file=config, verbosity=verbosity, output_format=output_format)
517 if show_config:
518 show_config_callback()
519 elif version:
520 version_callback()
521 elif not ctx.invoked_subcommand:
522 warn("Missing subcommand. Try `su6 --help` for more info.")
523 # else: just continue
526if __name__ == "__main__": # pragma: no cover
527 app()