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

265 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-08 13:56 +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 

15 

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 

24 

25GREEN_CIRCLE = "🟢" 

26YELLOW_CIRCLE = "🟡" 

27RED_CIRCLE = "🔴" 

28 

29EXIT_CODE_SUCCESS = 0 

30EXIT_CODE_ERROR = 1 

31EXIT_CODE_COMMAND_NOT_FOUND = 127 

32 

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

34 

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] 

45 

46 

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

52 

53 

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

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 

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

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 

114def 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 

154class 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 

235Verbosity_Comparable = Verbosity | str | int 

236 

237DEFAULT_VERBOSITY = Verbosity.normal 

238 

239 

240class 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 

268DEFAULT_FORMAT = Format.text 

269 

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

271 

272DEFAULT_BADGE = "coverage.svg" 

273 

274 

275class 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 

293 if self not in self._instances: 

294 self._instances[self] = super(SingletonMeta, self).__call__(*args, **kwargs) 

295 

296 return self._instances[self] 

297 

298 @staticmethod 

299 def clear(instance: "T_SingletonInstance" = None) -> None: 

300 """ 

301 Use to remove old instances. 

302 

303 (otherwise e.g. pytest will crash) 

304 """ 

305 if instance: 

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

307 else: 

308 SingletonMeta._instances.clear() 

309 

310 

311class Singleton(metaclass=SingletonMeta): 

312 ... 

313 

314 

315T_SingletonInstance = typing.Type[Singleton] 

316 

317 

318class AbstractConfig(Singleton): 

319 """ 

320 Used by state.config and plugin configs. 

321 """ 

322 

323 _strict = True 

324 

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

326 """ 

327 Set the config values. 

328 

329 Args: 

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

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

332 """ 

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

334 if value is None and not allow_none: 

335 continue 

336 

337 if strict and key not in self.__annotations__: 

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

339 

340 setattr(self, key, value) 

341 

342 

343@dataclass 

344class Config(AbstractConfig): 

345 """ 

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

347 

348 Also accessible via state.config 

349 """ 

350 

351 directory: str = "." 

352 pyproject: str = "pyproject.toml" 

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

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

355 stop_after_first_failure: bool = False 

356 

357 ### pytest ### 

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

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

360 

361 def __post_init__(self) -> None: 

362 """ 

363 Update the value of badge to the default path. 

364 """ 

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

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

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

368 self.badge = DEFAULT_BADGE 

369 

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

371 """ 

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

373 """ 

374 if self.include: 

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

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

377 return tools 

378 elif self.exclude: 

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

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

381 return options 

382 

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

384 """ 

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

386 

387 Used to later look up Plugin config. 

388 """ 

389 self.__raw.update(raw) 

390 

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

392 """ 

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

394 """ 

395 return self.__raw or {} 

396 

397 

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

399 

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

401 

402 

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

404 """ 

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

406 

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

408 """ 

409 try: 

410 _check_type(value, expected_type) 

411 return True 

412 except TypeCheckError: 

413 return False 

414 

415 

416@dataclass 

417class ConfigError(Exception): 

418 """ 

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

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

421 """ 

422 

423 key: str 

424 value: typing.Any 

425 expected_type: type 

426 

427 def __post_init__(self) -> None: 

428 """ 

429 Store the actual type of the config variable. 

430 """ 

431 self.actual_type = type(self.value) 

432 

433 def __str__(self) -> str: 

434 """ 

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

436 """ 

437 return ( 

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

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

440 ) 

441 

442 

443T = typing.TypeVar("T") 

444 

445 

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

447 """ 

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

449 

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

451 """ 

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

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

454 compare = data.get(key) 

455 if compare is None: 

456 # skip! 

457 continue 

458 if not check_type(compare, _type): 

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

460 

461 final[key] = compare 

462 return final 

463 

464 

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

466 """ 

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

468 

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

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

471 """ 

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

473 

474 

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

476 """ 

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

478 

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

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

481 

482 Args: 

483 overwrites: cli arguments can overwrite the config toml. 

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

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

486 """ 

487 if toml_path is None: 

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

489 

490 if not toml_path: 

491 return None 

492 

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

494 full_config = tomllib.load(f) 

495 

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

497 su6_config_dict |= overwrites 

498 

499 su6_config_dict["pyproject"] = toml_path 

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

501 su6_config_dict = _convert_config(su6_config_dict) 

502 su6_config_dict = _ensure_types(su6_config_dict, Config.__annotations__) 

503 

504 config = Config(**su6_config_dict) 

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

506 return config 

507 

508 

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

510 """ 

511 Load the relevant pyproject.toml config settings. 

512 

513 Args: 

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

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

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

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

518 """ 

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

520 overwrites = _convert_config(overwrites) 

521 

522 try: 

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

524 return config 

525 raise ValueError("Falsey config?") 

526 except Exception as e: 

527 # something went wrong parsing config, use defaults 

528 if verbosity > 3: 

529 # verbosity = debug 

530 raise e 

531 elif verbosity > 2: 

532 # verbosity = verbose 

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

534 return Config(**overwrites) 

535 

536 

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

538 """ 

539 'print' but with blue text. 

540 """ 

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

542 

543 

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

545 """ 

546 'print' but with yellow text. 

547 """ 

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

549 

550 

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

552 """ 

553 'print' but with red text. 

554 """ 

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

556 

557 

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

559 """ 

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

561 """ 

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

563 

564 

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

566 """ 

567 Print stdout in yellow and stderr in red. 

568 """ 

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

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

571 warn(stdout) 

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

573 danger(stderr) 

574 

575 

576@dataclass() 

577class ApplicationState: 

578 """ 

579 Application State - global user defined variables. 

580 

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

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

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

584 

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

586 """ 

587 

588 verbosity: Verbosity = DEFAULT_VERBOSITY 

589 output_format: Format = DEFAULT_FORMAT 

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

591 config: MaybeConfig = None 

592 

593 def __post_init__(self) -> None: 

594 """ 

595 Store registered plugin config. 

596 """ 

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

598 

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

600 """ 

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

602 

603 Also updates attached plugin configs. 

604 """ 

605 if "verbosity" in overwrites: 

606 self.verbosity = overwrites["verbosity"] 

607 if "config_file" in overwrites: 

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

609 if "output_format" in overwrites: 

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

611 

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

613 self._setup_plugin_config_defaults() 

614 return self.config 

615 

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

617 """ 

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

619 

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

621 """ 

622 self._plugins[name] = config_cls 

623 

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

625 """ 

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

627 

628 Example: 

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

630 class MoreDemoConfig(PluginConfig): 

631 more: bool 

632 

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

634 """ 

635 config = self.get_config() 

636 raw = config.get_raw() 

637 

638 parts = key.split(".") 

639 while parts: 

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

641 

642 return raw 

643 

644 def _setup_plugin_config_defaults(self) -> None: 

645 """ 

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

647 """ 

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

649 plugin_config = self._get_plugin_specific_config_from_raw(name) 

650 

651 plugin_config = _convert_config(plugin_config) 

652 if config_instance._strict: 

653 plugin_config = _ensure_types(plugin_config, config_instance.__annotations__) 

654 

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

656 config_instance.update(**plugin_config) 

657 

658 def get_config(self) -> Config: 

659 """ 

660 Get a filled config instance. 

661 """ 

662 return self.config or self.load_config() 

663 

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

665 """ 

666 Overwrite default/toml settings with cli values. 

667 

668 Example: 

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

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

671 """ 

672 existing_config = self.get_config() 

673 

674 values = _convert_config(values) 

675 existing_config.update(**values) 

676 return existing_config 

677 

678 

679state = ApplicationState()