Coverage for src/su6/core.py: 100%

1"""

2 This file contains internal helpers used by cli.py.

3"""

4 import enum

5 import functools

6 import inspect

7 import json

8 import operator

9 import os

10 import sys

11 import tomllib

12 import types

13 import typing

14 from dataclasses import dataclass, field

15

16 import black.files

17 import plumbum.commands.processes as pb

18 import typer

19 from plumbum import local

20 from plumbum.machines import LocalCommand

21 from rich import print

22 from typeguard import TypeCheckError

23 from typeguard import check_type as _check_type

24

25 GREEN_CIRCLE = "🟢"

26 YELLOW_CIRCLE = "🟡"

27 RED_CIRCLE = "🔴"

28

29 EXIT_CODE_SUCCESS = 0

30 EXIT_CODE_ERROR = 1

31 EXIT_CODE_COMMAND_NOT_FOUND = 127

32

33 PlumbumError = (pb.ProcessExecutionError, pb.ProcessTimedOut, pb.ProcessLineTimedOut, pb.CommandNotFound)

34

35# a Command can return these:

36 T_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

39 T_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

41 T_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

44 T_Outer_Wrapper: typing.TypeAlias = typing.Callable[[T_Command], T_Inner_Wrapper]

45

46

47 def 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))

52

53

54 def 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).

57

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.

61

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)})

67

68

69 def 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).

73

74 Usage:

75 > @app.command()

76 > @with_exit_code()

77 def some_command(): ...

78

79 When calling a command from a different command, _suppress=True can be added to not raise an Exit exception.

80 """

81

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())

87

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])

95

96 if result is None:

97 # assume no issue then

98 result = 0

99

100 if (retcode := int(result)) and not _suppress:

101 raise typer.Exit(code=retcode)

102

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

106

107 return retcode

108

109 return inner_wrapper

110

111 return outer_wrapper

112

113

114 def run_tool(tool: str, *args: str) -> int:

115 """

116 Abstraction to run one of the cli checking tools and process its output.

117

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]

124

125 if state.verbosity >= 3:

126 log_command(cmd, args)

127

128 result = cmd(*args)

129

130 if state.output_format == "text":

131 print(GREEN_CIRCLE, tool)

132

133 if state.verbosity > 2: # pragma: no cover

134 log_cmd_output(result)

135

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!")

140

141 if state.output_format == "text":

142 print(YELLOW_CIRCLE, tool)

143

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)

148

149 if state.verbosity > 1:

150 log_cmd_output(e.stdout, e.stderr)

151 return EXIT_CODE_ERROR # general error

152

153

154 class Verbosity(enum.Enum):

155 """

156 Verbosity is used with the --verbose argument of the cli commands.

157 """

158

159 # typer enum can only be string

160 quiet = "1"

161 normal = "2"

162 verbose = "3"

163 debug = "4" # only for internal use

164

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 <, <=, ==, >=, >.

173

174 This enum can be compared with integers, strings and other Verbosity instances.

175

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))

188

189 def __gt__(self, other: "Verbosity_Comparable") -> bool:

190 """

191 Magic method for self > other.

192 """

193 return self._compare(self, other, operator.gt)

194

195 def __ge__(self, other: "Verbosity_Comparable") -> bool:

196 """

197 Method magic for self >= other.

198 """

199 return self._compare(self, other, operator.ge)

200

201 def __lt__(self, other: "Verbosity_Comparable") -> bool:

202 """

203 Magic method for self < other.

204 """

205 return self._compare(self, other, operator.lt)

206

207 def __le__(self, other: "Verbosity_Comparable") -> bool:

208 """

209 Magic method for self <= other.

210 """

211 return self._compare(self, other, operator.le)

212

213 def __eq__(self, other: typing.Union["Verbosity", str, int, object]) -> bool:

214 """

215 Magic method for self == other.

216

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)

227

228 def __hash__(self) -> int:

229 """

230 Magic method for `hash(self)`, also required for Typer to work.

231 """

232 return hash(self.value)

233

234

235 Verbosity_Comparable = Verbosity | str | int

236

237 DEFAULT_VERBOSITY = Verbosity.normal

238

239

240 class Format(enum.Enum):

241 """

242 Options for su6 --format.

243 """

244

245 text = "text"

246 json = "json"

247

248 def __eq__(self, other: object) -> bool:

249 """

250 Magic method for self == other.

251

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

260

261 def __hash__(self) -> int:

262 """

263 Magic method for `hash(self)`, also required for Typer to work.

264 """

265 return hash(self.value)

266

267

268 DEFAULT_FORMAT = Format.text

269

270 C = typing.TypeVar("C", bound=T_Command)

271

272 DEFAULT_BADGE = "coverage.svg"

273

274

275 class SingletonMeta(type):

276 """

277 Every instance of a singleton shares the same object underneath.

278

279 Can be used as a metaclass:

280 Example:

281 class AbstractConfig(metaclass=Singleton):

282

283 Source: https://stackoverflow.com/questions/6760685/creating-a-singleton-in-python

284 """

285

286 _instances: dict[typing.Type[typing.Any], typing.Type[typing.Any]] = {}

287

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(SingletonMeta, self).__call__(*args, **kwargs)

294

295 return self._instances[self]

296

297 @staticmethod

298 def clear(instance: "Singleton" = None) -> None:

299 """

300 Use to remove old instances.

301

302 (otherwise e.g. pytest will crash)

303 """

304 if instance:

305 SingletonMeta._instances.pop(instance.__class__, None)

306 else:

307 SingletonMeta._instances.clear()

308

309

310 class Singleton(metaclass=SingletonMeta):

311 """

312 Mixin to make a class a singleton.

313 """

314

315

316 class AbstractConfig(Singleton):

317 """

318 Used by state.config and plugin configs.

319 """

320

321 _strict = True

322

323 def update(self, strict: bool = True, allow_none: bool = False, **kw: typing.Any) -> None:

324 """

325 Set the config values.

326

327 Args:

328 strict: by default, setting a new/unannotated property will raise an error. Set strict to False to allow it.

329 allow_none: by default, None values are filtered away. Set to True to allow them.

330 """

331 for key, value in kw.items():

332 if value is None and not allow_none:

333 continue

334

335 if strict and key not in self.__annotations__:

336 raise KeyError(f"{self.__class__.__name__} does not have a property {key} and strict mode is enabled.")

337

338 setattr(self, key, value)

339

340

341@dataclass

342 class Config(AbstractConfig):

343 """

344 Used as typed version of the [tool.su6] part of pyproject.toml.

345

346 Also accessible via state.config

347 """

348

349 directory: str = "."

350 pyproject: str = "pyproject.toml"

351 include: list[str] = field(default_factory=list)

352 exclude: list[str] = field(default_factory=list)

353 stop_after_first_failure: bool = False

354

355 ### pytest ###

356 coverage: typing.Optional[float] = None # only relevant for pytest

357 badge: bool | str = False # only relevant for pytest

358

359 def __post_init__(self) -> None:

360 """

361 Update the value of badge to the default path.

362 """

363 self.__raw: dict[str, typing.Any] = {}

364 if self.badge is True: # pragma: no cover

365 # no cover because pytest can't test pytest :C

366 self.badge = DEFAULT_BADGE

367

368 def determine_which_to_run(self, options: list[C]) -> list[C]:

369 """

370 Filter out any includes/excludes from pyproject.toml (first check include, then exclude).

371 """

372 if self.include:

373 tools = [_ for _ in options if _.__name__ in self.include]

374 tools.sort(key=lambda f: self.include.index(f.__name__))

375 return tools

376 elif self.exclude:

377 return [_ for _ in options if _.__name__ not in self.exclude]

378 # if no include or excludes passed, just run all!

379 return options

380

381 def set_raw(self, raw: dict[str, typing.Any]) -> None:

382 """

383 Set the raw config dict (from pyproject.toml).

384

385 Used to later look up Plugin config.

386 """

387 self.__raw.update(raw)

388

389 def get_raw(self) -> dict[str, typing.Any]:

390 """

391 Get the raw config dict (to load Plugin config).

392 """

393 return self.__raw or {}

394

395

396 MaybeConfig: typing.TypeAlias = typing.Optional[Config]

397

398 T_typelike: typing.TypeAlias = type | types.UnionType | types.UnionType

399

400

401 def check_type(value: typing.Any, expected_type: T_typelike) -> bool:

402 """

403 Given a variable, check if it matches 'expected_type' (which can be a Union, parameterized generic etc.).

404

405 Based on typeguard but this returns a boolean instead of returning the value or throwing a TypeCheckError

406 """

407 try:

408 _check_type(value, expected_type)

409 return True

410 except TypeCheckError:

411 return False

412

413

414@dataclass

415 class ConfigError(Exception):

416 """

417 Raised if pyproject.toml [su6.tool] contains a variable of \

418 which the type does not match that of the corresponding key in Config.

419 """

420

421 key: str

422 value: typing.Any

423 expected_type: type

424

425 def __post_init__(self) -> None:

426 """

427 Store the actual type of the config variable.

428 """

429 self.actual_type = type(self.value)

430

431 def __str__(self) -> str:

432 """

433 Custom error message based on dataclass values and calculated actual type.

434 """

435 return (

436 f"Config key '{self.key}' had a value ('{self.value}') with a type (`{self.actual_type}`) "

437 f"that was not expected: `{self.expected_type}` is the required type."

438 )

439

440

441 T = typing.TypeVar("T")

442

443

444 def _ensure_types(data: dict[str, T], annotations: dict[str, type]) -> dict[str, T | None]:

445 """

446 Make sure all values in 'data' are in line with the ones stored in 'annotations'.

447

448 If an annotated key in missing from data, it will be filled with None for convenience.

449 """

450 final: dict[str, T | None] = {}

451 for key, _type in annotations.items():

452 compare = data.get(key)

453 if compare is None:

454 # skip!

455 continue

456 if not check_type(compare, _type):

457 raise ConfigError(key, value=compare, expected_type=_type)

458

459 final[key] = compare

460 return final

461

462

463 def _convert_config(items: dict[str, T]) -> dict[str, T]:

464 """

465 Converts the config dict (from toml) or 'overwrites' dict in two ways.

466

467 1. removes any items where the value is None, since in that case the default should be used;

468 2. replaces '-' in keys with '_' so it can be mapped to the Config properties.

469 """

470 return {k.replace("-", "_"): v for k, v in items.items() if v is not None}

471

472

473 def _get_su6_config(overwrites: dict[str, typing.Any], toml_path: str = None) -> MaybeConfig:

474 """

475 Parse the users pyproject.toml (found using black's logic) and extract the tool.su6 part.

476

477 The types as entered in the toml are checked using _ensure_types,

478 to make sure there isn't a string implicitly converted to a list of characters or something.

479

480 Args:

481 overwrites: cli arguments can overwrite the config toml.

482 toml_path: by default, black will search for a relevant pyproject.toml.

483 If a toml_path is provided, that file will be used instead.

484 """

485 if toml_path is None:

486 toml_path = black.files.find_pyproject_toml((os.getcwd(),))

487

488 if not toml_path:

489 return None

490

491 with open(toml_path, "rb") as f:

492 full_config = tomllib.load(f)

493

494 su6_config_dict = full_config["tool"]["su6"]

495 su6_config_dict |= overwrites

496

497 su6_config_dict["pyproject"] = toml_path

498 # first convert the keys, then ensure types. Otherwise, non-matching keys may be removed!

499 su6_config_dict = _convert_config(su6_config_dict)

500 su6_config_dict = _ensure_types(su6_config_dict, Config.__annotations__)

501

502 config = Config(**su6_config_dict)

503 config.set_raw(full_config["tool"]["su6"])

504 return config

505

506

507 def get_su6_config(verbosity: Verbosity = DEFAULT_VERBOSITY, toml_path: str = None, **overwrites: typing.Any) -> Config:

508 """

509 Load the relevant pyproject.toml config settings.

510

511 Args:

512 verbosity: if something goes wrong, level 3+ will show a warning and 4+ will raise the exception.

513 toml_path: --config can be used to use a different file than ./pyproject.toml

514 overwrites (dict[str, typing.Any): cli arguments can overwrite the config toml.

515 If a value is None, the key is not overwritten.

516 """

517 # strip out any 'overwrites' with None as value

518 overwrites = _convert_config(overwrites)

519

520 try:

521 if config := _get_su6_config(overwrites, toml_path=toml_path):

522 return config

523 raise ValueError("Falsey config?")

524 except Exception as e:

525 # something went wrong parsing config, use defaults

526 if verbosity > 3:

527 # verbosity = debug

528 raise e

529 elif verbosity > 2:

530 # verbosity = verbose

531 print("Error parsing pyproject.toml, falling back to defaults.", file=sys.stderr)

532 return Config(**overwrites)

533

534

535 def info(*args: str) -> None:

536 """

537 'print' but with blue text.

538 """

539 print(f"[blue]{' '.join(args)}[/blue]", file=sys.stderr)

540

541

542 def warn(*args: str) -> None:

543 """

544 'print' but with yellow text.

545 """

546 print(f"[yellow]{' '.join(args)}[/yellow]", file=sys.stderr)

547

548

549 def danger(*args: str) -> None:

550 """

551 'print' but with red text.

552 """

553 print(f"[red]{' '.join(args)}[/red]", file=sys.stderr)

554

555

556 def log_command(command: LocalCommand, args: typing.Iterable[str]) -> None:

557 """

558 Print a Plumbum command in blue, prefixed with > to indicate it's a shell command.

559 """

560 info(f"> {command[*args]}")

561

562

563 def log_cmd_output(stdout: str = "", stderr: str = "") -> None:

564 """

565 Print stdout in yellow and stderr in red.

566 """

567 # if you are logging stdout, it's probably because it's not a successful run.

568 # However, it's not stderr so we make it warning-yellow

569 warn(stdout)

570 # probably more important error stuff, so stderr goes last:

571 danger(stderr)

572

573

574@dataclass()

575 class ApplicationState:

576 """

577 Application State - global user defined variables.

578

579 State contains generic variables passed BEFORE the subcommand (so --verbosity, --config, ...),

580 whereas Config contains settings from the config toml file, updated with arguments AFTER the subcommand

581 (e.g. su6 subcommand <directory> --flag), directory and flag will be updated in the config and not the state.

582

583 To summarize: 'state' is applicable to all commands and config only to specific ones.

584 """

585

586 verbosity: Verbosity = DEFAULT_VERBOSITY

587 output_format: Format = DEFAULT_FORMAT

588 config_file: typing.Optional[str] = None # will be filled with black's search logic

589 config: MaybeConfig = None

590

591 def __post_init__(self) -> None:

592 """

593 Store registered plugin config.

594 """

595 self._plugins: dict[str, AbstractConfig] = {}

596

597 def load_config(self, **overwrites: typing.Any) -> Config:

598 """

599 Load the su6 config from pyproject.toml (or other config_file) with optional overwriting settings.

600

601 Also updates attached plugin configs.

602 """

603 if "verbosity" in overwrites:

604 self.verbosity = overwrites["verbosity"]

605 if "config_file" in overwrites:

606 self.config_file = overwrites.pop("config_file")

607 if "output_format" in overwrites:

608 self.output_format = overwrites.pop("output_format")

609

610 self.config = get_su6_config(toml_path=self.config_file, **overwrites)

611 self._setup_plugin_config_defaults()

612 return self.config

613

614 def attach_plugin_config(self, name: str, config_cls: AbstractConfig) -> None:

615 """

616 Add a new plugin-specific config to be loaded later with load_config().

617

618 Called from plugins.py when an @registered PluginConfig is found.

619 """

620 self._plugins[name] = config_cls

621

622 def _get_plugin_specific_config_from_raw(self, key: str) -> dict[str, typing.Any]:

623 """

624 Plugin Config keys can be nested, so this method traverses the raw config dictionary to find the right level.

625

626 Example:

627 @register(config_key="demo.extra")

628 class MoreDemoConfig(PluginConfig):

629 more: bool

630

631 -> data['tool']['su6']['demo']['extra']

632 """

633 config = self.get_config()

634 raw = config.get_raw()

635

636 parts = key.split(".")

637 while parts:

638 raw = raw[parts.pop(0)]

639

640 return raw

641

642 def _setup_plugin_config_defaults(self) -> None:

643 """

644 After load_config, the raw data is used to also fill registered plugin configs.

645 """

646 for name, config_instance in self._plugins.items():

647 plugin_config = self._get_plugin_specific_config_from_raw(name)

648

649 plugin_config = _convert_config(plugin_config)

650 if config_instance._strict:

651 plugin_config = _ensure_types(plugin_config, config_instance.__annotations__)

652

653 # config_cls should be a singleton so this instance == plugin instance

654 config_instance.update(**plugin_config)

655

656 def get_config(self) -> Config:

657 """

658 Get a filled config instance.

659 """

660 return self.config or self.load_config()

661

662 def update_config(self, **values: typing.Any) -> Config:

663 """

664 Overwrite default/toml settings with cli values.

665

666 Example:

667 `config = state.update_config(directory='src')`

668 This will update the state's config and return the same object with the updated settings.

669 """

670 existing_config = self.get_config()

671

672 values = _convert_config(values)

673 existing_config.update(**values)

674 return existing_config

675

676

677 state = ApplicationState()