Coverage for src/su6/core.py: 100%
256 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-06 22:30 +0200
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-06 22:30 +0200
1"""
2This file contains internal helpers used by cli.py.
3"""
4import enum
5import functools
6import inspect
7import json
8import operator
9import os
10import sys
11import tomllib
12import types
13import typing
14from dataclasses import dataclass, field
16import black.files
17import plumbum.commands.processes as pb
18import typer
19from plumbum import local
20from plumbum.machines import LocalCommand
21from rich import print
22from typeguard import TypeCheckError
23from typeguard import check_type as _check_type
25GREEN_CIRCLE = "🟢"
26YELLOW_CIRCLE = "🟡"
27RED_CIRCLE = "🔴"
29EXIT_CODE_SUCCESS = 0
30EXIT_CODE_ERROR = 1
31EXIT_CODE_COMMAND_NOT_FOUND = 127
33PlumbumError = (pb.ProcessExecutionError, pb.ProcessTimedOut, pb.ProcessLineTimedOut, pb.CommandNotFound)
35# a Command can return these:
36T_Command_Return = bool | int | None
37# ... here indicates any number of args/kwargs:
38# t command is any @app.command() method, which can have anything as input and bool or int as output
39T_Command: typing.TypeAlias = typing.Callable[..., T_Command_Return]
40# t inner wrapper calls t_command and handles its output. This wrapper gets the same (kw)args as above so ... again
41T_Inner_Wrapper: typing.TypeAlias = typing.Callable[..., int | None]
42# outer wrapper gets the t_command method as input and outputs the inner wrapper,
43# so that gets called() with args and kwargs when that method is used from the cli
44T_Outer_Wrapper: typing.TypeAlias = typing.Callable[[T_Command], T_Inner_Wrapper]
47def print_json(data: typing.Any) -> None:
48 """
49 Take a dict of {command: output} or the State and print it.
50 """
51 print(json.dumps(data, default=str))
54def dump_tools_with_results(tools: list[T_Command], results: list[int | bool | None]) -> None:
55 """
56 When using format = json, dump the success of each tool in tools (-> exit code == 0).
58 This method is used in `all` and `fix` (with a list of tools) and in 'with_exit_code' (with one tool).
59 'with_exit_code' does NOT use this method if the return value was a bool, because that's the return value of
60 'all' and 'fix' and those already dump a dict output themselves.
62 Args:
63 tools: list of commands that ran
64 results: list of return values from these commands
65 """
66 print_json({tool.__name__: not result for tool, result in zip(tools, results)})
69def with_exit_code() -> T_Outer_Wrapper:
70 """
71 Convert the return value of an app.command (bool or int) to an typer Exit with return code, \
72 Unless the return value is Falsey, in which case the default exit happens (with exit code 0 indicating success).
74 Usage:
75 > @app.command()
76 > @with_exit_code()
77 def some_command(): ...
79 When calling a command from a different command, _suppress=True can be added to not raise an Exit exception.
80 """
82 def outer_wrapper(func: T_Command) -> T_Inner_Wrapper:
83 @functools.wraps(func)
84 def inner_wrapper(*args: typing.Any, **kwargs: typing.Any) -> int:
85 _suppress = kwargs.pop("_suppress", False)
86 _ignore_exit_codes = kwargs.pop("_ignore", set())
88 result = func(*args, **kwargs)
89 if state.output_format == "json" and not _suppress and result is not None and not isinstance(result, bool):
90 # isinstance(True, int) -> True so not isinstance(result, bool)
91 # print {tool: success}
92 # but only if a retcode is returned,
93 # otherwise (True, False) assume the function handled printing itself.
94 dump_tools_with_results([func], [result])
96 if result is None:
97 # assume no issue then
98 result = 0
100 if (retcode := int(result)) and not _suppress:
101 raise typer.Exit(code=retcode)
103 if retcode in _ignore_exit_codes: # pragma: no cover
104 # there is an error code, but we choose to ignore it -> return 0
105 return EXIT_CODE_SUCCESS
107 return retcode
109 return inner_wrapper
111 return outer_wrapper
114def run_tool(tool: str, *args: str) -> int:
115 """
116 Abstraction to run one of the cli checking tools and process its output.
118 Args:
119 tool: the (bash) name of the tool to run.
120 args: cli args to pass to the cli bash tool
121 """
122 try:
123 cmd = local[tool]
125 if state.verbosity >= 3:
126 log_command(cmd, args)
128 result = cmd(*args)
130 if state.output_format == "text":
131 print(GREEN_CIRCLE, tool)
133 if state.verbosity > 2: # pragma: no cover
134 log_cmd_output(result)
136 return EXIT_CODE_SUCCESS # success
137 except pb.CommandNotFound: # pragma: no cover
138 if state.verbosity > 2:
139 warn(f"Tool {tool} not installed!")
141 if state.output_format == "text":
142 print(YELLOW_CIRCLE, tool)
144 return EXIT_CODE_COMMAND_NOT_FOUND # command not found
145 except pb.ProcessExecutionError as e:
146 if state.output_format == "text":
147 print(RED_CIRCLE, tool)
149 if state.verbosity > 1:
150 log_cmd_output(e.stdout, e.stderr)
151 return EXIT_CODE_ERROR # general error
154class Verbosity(enum.Enum):
155 """
156 Verbosity is used with the --verbose argument of the cli commands.
157 """
159 # typer enum can only be string
160 quiet = "1"
161 normal = "2"
162 verbose = "3"
163 debug = "4" # only for internal use
165 @staticmethod
166 def _compare(
167 self: "Verbosity",
168 other: "Verbosity_Comparable",
169 _operator: typing.Callable[["Verbosity_Comparable", "Verbosity_Comparable"], bool],
170 ) -> bool:
171 """
172 Abstraction using 'operator' to have shared functionality between <, <=, ==, >=, >.
174 This enum can be compared with integers, strings and other Verbosity instances.
176 Args:
177 self: the first Verbosity
178 other: the second Verbosity (or other thing to compare)
179 _operator: a callable operator (from 'operators') that takes two of the same types as input.
180 """
181 match other:
182 case Verbosity():
183 return _operator(self.value, other.value)
184 case int():
185 return _operator(int(self.value), other)
186 case str():
187 return _operator(int(self.value), int(other))
189 def __gt__(self, other: "Verbosity_Comparable") -> bool:
190 """
191 Magic method for self > other.
192 """
193 return self._compare(self, other, operator.gt)
195 def __ge__(self, other: "Verbosity_Comparable") -> bool:
196 """
197 Method magic for self >= other.
198 """
199 return self._compare(self, other, operator.ge)
201 def __lt__(self, other: "Verbosity_Comparable") -> bool:
202 """
203 Magic method for self < other.
204 """
205 return self._compare(self, other, operator.lt)
207 def __le__(self, other: "Verbosity_Comparable") -> bool:
208 """
209 Magic method for self <= other.
210 """
211 return self._compare(self, other, operator.le)
213 def __eq__(self, other: typing.Union["Verbosity", str, int, object]) -> bool:
214 """
215 Magic method for self == other.
217 'eq' is a special case because 'other' MUST be object according to mypy
218 """
219 if other is Ellipsis or other is inspect._empty:
220 # both instances of object; can't use Ellipsis or type(ELlipsis) = ellipsis as a type hint in mypy
221 # special cases where Typer instanciates its cli arguments,
222 # return False or it will crash
223 return False
224 if not isinstance(other, (str, int, Verbosity)):
225 raise TypeError(f"Object of type {type(other)} can not be compared with Verbosity")
226 return self._compare(self, other, operator.eq)
228 def __hash__(self) -> int:
229 """
230 Magic method for `hash(self)`, also required for Typer to work.
231 """
232 return hash(self.value)
235Verbosity_Comparable = Verbosity | str | int
237DEFAULT_VERBOSITY = Verbosity.normal
240class Format(enum.Enum):
241 """
242 Options for su6 --format.
243 """
245 text = "text"
246 json = "json"
248 def __eq__(self, other: object) -> bool:
249 """
250 Magic method for self == other.
252 'eq' is a special case because 'other' MUST be object according to mypy
253 """
254 if other is Ellipsis or other is inspect._empty:
255 # both instances of object; can't use Ellipsis or type(ELlipsis) = ellipsis as a type hint in mypy
256 # special cases where Typer instanciates its cli arguments,
257 # return False or it will crash
258 return False
259 return self.value == other
261 def __hash__(self) -> int:
262 """
263 Magic method for `hash(self)`, also required for Typer to work.
264 """
265 return hash(self.value)
268DEFAULT_FORMAT = Format.text
270C = typing.TypeVar("C", bound=T_Command)
272DEFAULT_BADGE = "coverage.svg"
275class Singleton(type):
276 """
277 Every instance of a singleton shares the same object underneath.
279 Can be used as a metaclass:
280 Example:
281 class AbstractConfig(metaclass=Singleton):
283 Source: https://stackoverflow.com/questions/6760685/creating-a-singleton-in-python
284 """
286 _instances: dict[typing.Type[typing.Any], typing.Type[typing.Any]] = {}
288 def __call__(self, *args: typing.Any, **kwargs: typing.Any) -> typing.Type[typing.Any]:
289 """
290 When a class is instantiated (e.g. `AbstractConfig()`), __call__ is called. This overrides the default behavior.
291 """
292 if self not in self._instances:
293 self._instances[self] = super(Singleton, self).__call__(*args, **kwargs)
294 return self._instances[self]
296 @staticmethod
297 def clear() -> None:
298 """
299 Use to remove old instances.
301 (otherwise e.g. pytest will crash)
302 """
303 Singleton._instances.clear()
306class AbstractConfig(metaclass=Singleton):
307 """
308 Used by state.config and plugin configs.
309 """
311 def update(self, strict: bool = True, **kw: typing.Any) -> None:
312 """
313 Set the config values.
315 Args:
316 strict: by default, setting a new/unannotated property will raise an error. Set strict to False to allow it.
317 """
318 for key, value in kw.items():
319 if strict and key not in self.__annotations__:
320 raise KeyError(f"{self.__class__.__name__} does not have a property {key} and strict mode is enabled.")
321 setattr(self, key, value)
324@dataclass
325class Config(AbstractConfig):
326 """
327 Used as typed version of the [tool.su6] part of pyproject.toml.
329 Also accessible via state.config
330 """
332 directory: str = "."
333 pyproject: str = "pyproject.toml"
334 include: list[str] = field(default_factory=list)
335 exclude: list[str] = field(default_factory=list)
336 stop_after_first_failure: bool = False
338 ### pytest ###
339 coverage: typing.Optional[float] = None # only relevant for pytest
340 badge: bool | str = False # only relevant for pytest
342 def __post_init__(self) -> None:
343 """
344 Update the value of badge to the default path.
345 """
346 self.__raw: dict[str, typing.Any] = {}
347 if self.badge is True: # pragma: no cover
348 # no cover because pytest can't test pytest :C
349 self.badge = DEFAULT_BADGE
351 def determine_which_to_run(self, options: list[C]) -> list[C]:
352 """
353 Filter out any includes/excludes from pyproject.toml (first check include, then exclude).
354 """
355 if self.include:
356 tools = [_ for _ in options if _.__name__ in self.include]
357 tools.sort(key=lambda f: self.include.index(f.__name__))
358 return tools
359 elif self.exclude:
360 return [_ for _ in options if _.__name__ not in self.exclude]
361 # if no include or excludes passed, just run all!
362 return options
364 def set_raw(self, raw: dict[str, typing.Any]) -> None:
365 """
366 Set the raw config dict (from pyproject.toml).
368 Used to later look up Plugin config.
369 """
370 self.__raw.update(raw)
372 def get_raw(self) -> dict[str, typing.Any]:
373 """
374 Get the raw config dict (to load Plugin config).
375 """
376 return self.__raw or {}
379MaybeConfig: typing.TypeAlias = typing.Optional[Config]
381T_typelike: typing.TypeAlias = type | types.UnionType | types.UnionType
384def check_type(value: typing.Any, expected_type: T_typelike) -> bool:
385 """
386 Given a variable, check if it matches 'expected_type' (which can be a Union, parameterized generic etc.).
388 Based on typeguard but this returns a boolean instead of returning the value or throwing a TypeCheckError
389 """
390 try:
391 _check_type(value, expected_type)
392 return True
393 except TypeCheckError:
394 return False
397@dataclass
398class ConfigError(Exception):
399 """
400 Raised if pyproject.toml [su6.tool] contains a variable of \
401 which the type does not match that of the corresponding key in Config.
402 """
404 key: str
405 value: typing.Any
406 expected_type: type
408 def __post_init__(self) -> None:
409 """
410 Store the actual type of the config variable.
411 """
412 self.actual_type = type(self.value)
414 def __str__(self) -> str:
415 """
416 Custom error message based on dataclass values and calculated actual type.
417 """
418 return (
419 f"Config key '{self.key}' had a value ('{self.value}') with a type (`{self.actual_type}`) "
420 f"that was not expected: `{self.expected_type}` is the required type."
421 )
424T = typing.TypeVar("T")
427def _ensure_types(data: dict[str, T], annotations: dict[str, type]) -> dict[str, T | None]:
428 """
429 Make sure all values in 'data' are in line with the ones stored in 'annotations'.
431 If an annotated key in missing from data, it will be filled with None for convenience.
432 """
433 final: dict[str, T | None] = {}
434 for key, _type in annotations.items():
435 compare = data.get(key)
436 if compare is None:
437 # skip!
438 continue
439 if not check_type(compare, _type):
440 raise ConfigError(key, value=compare, expected_type=_type)
442 final[key] = compare
443 return final
446def _convert_config(items: dict[str, T]) -> dict[str, T]:
447 """
448 Converts the config dict (from toml) or 'overwrites' dict in two ways.
450 1. removes any items where the value is None, since in that case the default should be used;
451 2. replaces '-' in keys with '_' so it can be mapped to the Config properties.
452 """
453 return {k.replace("-", "_"): v for k, v in items.items() if v is not None}
456def _get_su6_config(overwrites: dict[str, typing.Any], toml_path: str = None) -> MaybeConfig:
457 """
458 Parse the users pyproject.toml (found using black's logic) and extract the tool.su6 part.
460 The types as entered in the toml are checked using _ensure_types,
461 to make sure there isn't a string implicitly converted to a list of characters or something.
463 Args:
464 overwrites: cli arguments can overwrite the config toml.
465 toml_path: by default, black will search for a relevant pyproject.toml.
466 If a toml_path is provided, that file will be used instead.
467 """
468 if toml_path is None:
469 toml_path = black.files.find_pyproject_toml((os.getcwd(),))
471 if not toml_path:
472 return None
474 with open(toml_path, "rb") as f:
475 full_config = tomllib.load(f)
477 su6_config_dict = full_config["tool"]["su6"]
478 su6_config_dict |= overwrites
480 su6_config_dict["pyproject"] = toml_path
481 # first convert the keys, then ensure types. Otherwise, non-matching keys may be removed!
482 su6_config_dict = _convert_config(su6_config_dict)
483 su6_config_dict = _ensure_types(su6_config_dict, Config.__annotations__)
485 config = Config(**su6_config_dict)
486 config.set_raw(full_config["tool"]["su6"])
487 return config
490def get_su6_config(verbosity: Verbosity = DEFAULT_VERBOSITY, toml_path: str = None, **overwrites: typing.Any) -> Config:
491 """
492 Load the relevant pyproject.toml config settings.
494 Args:
495 verbosity: if something goes wrong, level 3+ will show a warning and 4+ will raise the exception.
496 toml_path: --config can be used to use a different file than ./pyproject.toml
497 overwrites (dict[str, typing.Any): cli arguments can overwrite the config toml.
498 If a value is None, the key is not overwritten.
499 """
500 # strip out any 'overwrites' with None as value
501 overwrites = _convert_config(overwrites)
503 try:
504 if config := _get_su6_config(overwrites, toml_path=toml_path):
505 return config
506 raise ValueError("Falsey config?")
507 except Exception as e:
508 # something went wrong parsing config, use defaults
509 if verbosity > 3:
510 # verbosity = debug
511 raise e
512 elif verbosity > 2:
513 # verbosity = verbose
514 print("Error parsing pyproject.toml, falling back to defaults.", file=sys.stderr)
515 return Config(**overwrites)
518def info(*args: str) -> None:
519 """
520 'print' but with blue text.
521 """
522 print(f"[blue]{' '.join(args)}[/blue]", file=sys.stderr)
525def warn(*args: str) -> None:
526 """
527 'print' but with yellow text.
528 """
529 print(f"[yellow]{' '.join(args)}[/yellow]", file=sys.stderr)
532def danger(*args: str) -> None:
533 """
534 'print' but with red text.
535 """
536 print(f"[red]{' '.join(args)}[/red]", file=sys.stderr)
539def log_command(command: LocalCommand, args: typing.Iterable[str]) -> None:
540 """
541 Print a Plumbum command in blue, prefixed with > to indicate it's a shell command.
542 """
543 info(f"> {command[*args]}")
546def log_cmd_output(stdout: str = "", stderr: str = "") -> None:
547 """
548 Print stdout in yellow and stderr in red.
549 """
550 # if you are logging stdout, it's probably because it's not a successful run.
551 # However, it's not stderr so we make it warning-yellow
552 warn(stdout)
553 # probably more important error stuff, so stderr goes last:
554 danger(stderr)
557@dataclass()
558class ApplicationState:
559 """
560 Application State - global user defined variables.
562 State contains generic variables passed BEFORE the subcommand (so --verbosity, --config, ...),
563 whereas Config contains settings from the config toml file, updated with arguments AFTER the subcommand
564 (e.g. su6 subcommand <directory> --flag), directory and flag will be updated in the config and not the state.
566 To summarize: 'state' is applicable to all commands and config only to specific ones.
567 """
569 verbosity: Verbosity = DEFAULT_VERBOSITY
570 output_format: Format = DEFAULT_FORMAT
571 config_file: typing.Optional[str] = None # will be filled with black's search logic
572 config: MaybeConfig = None
574 def __post_init__(self) -> None:
575 """
576 Store registered plugin config.
577 """
578 self._plugins: dict[str, AbstractConfig] = {}
580 def load_config(self, **overwrites: typing.Any) -> Config:
581 """
582 Load the su6 config from pyproject.toml (or other config_file) with optional overwriting settings.
584 Also updates attached plugin configs.
585 """
586 if "verbosity" in overwrites:
587 self.verbosity = overwrites["verbosity"]
588 if "config_file" in overwrites:
589 self.config_file = overwrites.pop("config_file")
590 if "output_format" in overwrites:
591 self.output_format = overwrites.pop("output_format")
593 self.config = get_su6_config(toml_path=self.config_file, **overwrites)
594 self._setup_plugin_config_defaults()
595 return self.config
597 def attach_plugin_config(self, name: str, config_cls: AbstractConfig) -> None:
598 """
599 Add a new plugin-specific config to be loaded later with load_config().
601 Called from plugins.py when an @registered PluginConfig is found.
602 """
603 self._plugins[name] = config_cls
605 def _get_plugin_specific_config_from_raw(self, key: str) -> dict[str, typing.Any]:
606 """
607 Plugin Config keys can be nested, so this method traverses the raw config dictionary to find the right level.
609 Example:
610 @register(config_key="demo.extra")
611 class MoreDemoConfig(PluginConfig):
612 more: bool
614 -> data['tool']['su6']['demo']['extra']
615 """
616 config = self.get_config()
617 raw = config.get_raw()
619 parts = key.split(".")
620 while parts:
621 raw = raw[parts.pop(0)]
623 return raw
625 def _setup_plugin_config_defaults(self) -> None:
626 """
627 After load_config, the raw data is used to also fill registered plugin configs.
628 """
629 for name, config_instance in self._plugins.items():
630 plugin_config = self._get_plugin_specific_config_from_raw(name)
632 plugin_config = _convert_config(plugin_config)
633 plugin_config = _ensure_types(plugin_config, config_instance.__annotations__)
635 # config_cls should be a singleton so this instance == plugin instance
636 config_instance.update(**plugin_config)
638 def get_config(self) -> Config:
639 """
640 Get a filled config instance.
641 """
642 return self.config or self.load_config()
644 def update_config(self, **values: typing.Any) -> Config:
645 """
646 Overwrite default/toml settings with cli values.
648 Example:
649 `config = state.update_config(directory='src')`
650 This will update the state's config and return the same object with the updated settings.
651 """
652 existing_config = self.get_config()
654 values = _convert_config(values)
655 existing_config.update(**values)
656 return existing_config
659state = ApplicationState()