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

271 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2024-03-20 17:12 +0100

1""" 

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

3""" 

4import enum 

5import functools 

6import inspect 

7import json 

8import operator 

9import pydoc 

10import sys 

11import types 

12import typing 

13from dataclasses import dataclass, field 

14from pathlib import Path 

15from typing import Any, Callable, Optional, TypeAlias, Union 

16 

17import configuraptor 

18import plumbum.commands.processes as pb 

19import tomli 

20import typer 

21from configuraptor import convert_config 

22from configuraptor.helpers import find_pyproject_toml 

23from plumbum import local 

24from plumbum.machines import LocalCommand 

25from rich import print 

26 

27if typing.TYPE_CHECKING: # pragma: no cover 

28 from .plugins import AnyRegistration 

29 

30GREEN_CIRCLE = "🟢" 

31YELLOW_CIRCLE = "🟡" 

32RED_CIRCLE = "🔴" 

33 

34EXIT_CODE_SUCCESS = 0 

35EXIT_CODE_ERROR = 1 

36EXIT_CODE_COMMAND_NOT_FOUND = 127 

37 

38 

39class ExitCodes: 

40 """ 

41 Store the possible EXIT_CODE_ items for ease of use (and autocomplete). 

42 """ 

43 

44 # enum but not really 

45 success = EXIT_CODE_SUCCESS 

46 error = EXIT_CODE_ERROR 

47 command_not_found = EXIT_CODE_COMMAND_NOT_FOUND 

48 

49 

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

51 

52# a Command can return these: 

53T_Command_Return = bool | int | None 

54# ... here indicates any number of args/kwargs: 

55# t command is any @app.command() method, which can have anything as input and bool or int as output 

56T_Command: TypeAlias = Callable[..., T_Command_Return] 

57# t inner wrapper calls t_command and handles its output. This wrapper gets the same (kw)args as above so ... again 

58T_Inner_Wrapper: TypeAlias = Callable[..., int | None] 

59# outer wrapper gets the t_command method as input and outputs the inner wrapper, 

60# so that gets called() with args and kwargs when that method is used from the cli 

61T_Outer_Wrapper: TypeAlias = Callable[[T_Command], T_Inner_Wrapper] 

62 

63 

64def print_json(data: Any) -> None: 

65 """ 

66 Take a dict of {command: output} or the State and print it. 

67 """ 

68 indent = state.get_config().json_indent or None 

69 # none is different from 0 for the indent kwarg, but 0 will be changed to None for this module 

70 print(json.dumps(data, default=str, indent=indent)) 

71 

72 

73def dump_tools_with_results(tools: list[T_Command], results: list[int | bool | None]) -> None: 

74 """ 

75 When using format = json, dump the success of each tool in tools (-> exit code == 0). 

76 

77 This method is used in `all` and `fix` (with a list of tools) and in 'with_exit_code' (with one tool). 

78 'with_exit_code' does NOT use this method if the return value was a bool, because that's the return value of 

79 'all' and 'fix' and those already dump a dict output themselves. 

80 

81 Args: 

82 tools: list of commands that ran 

83 results: list of return values from these commands 

84 """ 

85 print_json({tool.__name__: not result for tool, result in zip(tools, results)}) 

86 

87 

88def with_exit_code() -> T_Outer_Wrapper: 

89 """ 

90 Convert the return value of an app.command (bool or int) to an typer Exit with return code, \ 

91 Unless the return value is Falsey, in which case the default exit happens (with exit code 0 indicating success). 

92 

93 Usage: 

94 > @app.command() 

95 > @with_exit_code() 

96 def some_command(): ... 

97 

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

99 """ 

100 

101 def outer_wrapper(func: T_Command) -> T_Inner_Wrapper: 

102 @functools.wraps(func) 

103 def inner_wrapper(*args: Any, **kwargs: Any) -> int: 

104 _suppress = kwargs.pop("_suppress", False) 

105 _ignore_exit_codes = kwargs.pop("_ignore", set()) 

106 

107 result = func(*args, **kwargs) 

108 if state.output_format == "json" and not _suppress and result is not None and not isinstance(result, bool): 

109 # isinstance(True, int) -> True so not isinstance(result, bool) 

110 # print {tool: success} 

111 # but only if a retcode is returned, 

112 # otherwise (True, False) assume the function handled printing itself. 

113 dump_tools_with_results([func], [result]) 

114 

115 if result is None: 

116 # assume no issue then 

117 result = 0 

118 

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

120 raise typer.Exit(code=retcode) 

121 

122 if retcode in _ignore_exit_codes: # pragma: no cover 

123 # there is an error code, but we choose to ignore it -> return 0 

124 return ExitCodes.success 

125 

126 return retcode 

127 

128 return inner_wrapper 

129 

130 return outer_wrapper 

131 

132 

133def is_available_via_python(tool: str) -> bool: 

134 """ 

135 Sometimes, an executable is not available in PATH (e.g. via pipx) but it is available as `python -m something`. 

136 

137 This tries to test for that. 

138 May not work for exceptions like 'semantic-release'/'semantic_release' (python-semantic-release) 

139 """ 

140 try: 

141 pydoc.render_doc(tool) 

142 return True 

143 except ImportError: 

144 return False 

145 

146 

147def is_installed(tool: str, python_fallback: bool = True) -> bool: 

148 """ 

149 Check whether a certain tool is installed (/ can be found via 'which'). 

150 """ 

151 try: 

152 return bool(local["which"](tool)) 

153 except pb.ProcessExecutionError: 

154 return is_available_via_python(tool) if python_fallback else False 

155 

156 

157def on_tool_success(tool_name: str, result: str) -> int: 

158 """ 

159 Last step of run_tool or run_tool_via_python on success. 

160 """ 

161 if state.output_format == "text": 

162 print(GREEN_CIRCLE, tool_name) 

163 

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

165 log_cmd_output(result) 

166 

167 return ExitCodes.success # success 

168 

169 

170def on_tool_missing(tool_name: str) -> int: 

171 """ 

172 If tool can't be found in both run_tool and run_tool_via_python. 

173 """ 

174 if state.verbosity > 2: 

175 warn(f"Tool {tool_name} not installed!") 

176 

177 if state.output_format == "text": 

178 print(YELLOW_CIRCLE, tool_name) 

179 

180 return ExitCodes.command_not_found # command not found 

181 

182 

183def on_tool_failure(tool_name: str, e: pb.ProcessExecutionError) -> int: 

184 """ 

185 If tool fails in run_tool or run_tool_via_python. 

186 """ 

187 if state.output_format == "text": 

188 print(RED_CIRCLE, tool_name) 

189 

190 if state.verbosity > 1: 

191 log_cmd_output(e.stdout, e.stderr) 

192 return ExitCodes.error # general error 

193 

194 

195def run_tool_via_python(tool_name: str, *args: str) -> int: 

196 """ 

197 Fallback: try `python -m tool ...` instead of `tool ...`. 

198 """ 

199 cmd = local[sys.executable]["-m", tool_name] 

200 if state.verbosity >= 3: 

201 log_command(cmd, args) 

202 

203 try: 

204 result = cmd(*args) 

205 return on_tool_success(tool_name, result) 

206 except pb.ProcessExecutionError as e: 

207 if "No module named" in e.stderr: 

208 return on_tool_missing(tool_name) 

209 

210 return on_tool_failure(tool_name, e) 

211 

212 

213def run_tool(tool: str, *_args: str) -> int: 

214 """ 

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

216 

217 Args: 

218 tool: the (bash) name of the tool to run. 

219 _args: cli args to pass to the cli bash tool 

220 """ 

221 tool_name = tool.split("/")[-1] 

222 

223 args = list(_args) 

224 

225 if state.config and (extra_flags := state.config.get_default_flags(tool)): 

226 args.extend(extra_flags) 

227 

228 try: 

229 cmd = local[tool] 

230 except pb.CommandNotFound: # pragma: no cover 

231 return run_tool_via_python(tool_name, *args) 

232 

233 if state.verbosity >= 3: 

234 log_command(cmd, args) 

235 

236 try: 

237 result = cmd(*args) 

238 return on_tool_success(tool_name, result) 

239 

240 except pb.ProcessExecutionError as e: 

241 return on_tool_failure(tool_name, e) 

242 

243 

244class Verbosity(enum.Enum): 

245 """ 

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

247 """ 

248 

249 # typer enum can only be string 

250 quiet = "1" 

251 normal = "2" 

252 verbose = "3" 

253 debug = "4" # only for internal use 

254 

255 @staticmethod 

256 def _compare( 

257 self: "Verbosity", 

258 other: "Verbosity_Comparable", 

259 _operator: Callable[["Verbosity_Comparable", "Verbosity_Comparable"], bool], 

260 ) -> bool: 

261 """ 

262 Abstraction using 'operator' to have shared functionality between <, <=, ==, >=, >. 

263 

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

265 

266 Args: 

267 self: the first Verbosity 

268 other: the second Verbosity (or other thing to compare) 

269 _operator: a callable operator (from 'operators') that takes two of the same types as input. 

270 """ 

271 match other: 

272 case Verbosity(): 

273 return _operator(self.value, other.value) 

274 case int(): 

275 return _operator(int(self.value), other) 

276 case str(): 

277 return _operator(int(self.value), int(other)) 

278 

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

280 """ 

281 Magic method for self > other. 

282 """ 

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

284 

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

286 """ 

287 Method magic for self >= other. 

288 """ 

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

290 

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

292 """ 

293 Magic method for self < other. 

294 """ 

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

296 

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

298 """ 

299 Magic method for self <= other. 

300 """ 

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

302 

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

304 """ 

305 Magic method for self == other. 

306 

307 'eq' is a special case because 'other' MUST be object according to mypy 

308 """ 

309 if other is Ellipsis or other is inspect._empty: 

310 # both instances of object; can't use Ellipsis or type(ELlipsis) = ellipsis as a type hint in mypy 

311 # special cases where Typer instanciates its cli arguments, 

312 # return False or it will crash 

313 return False 

314 if not isinstance(other, (str, int, Verbosity)): 

315 raise TypeError(f"Object of type {type(other)} can not be compared with Verbosity") 

316 return self._compare(self, other, operator.eq) 

317 

318 def __hash__(self) -> int: 

319 """ 

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

321 """ 

322 return hash(self.value) 

323 

324 

325Verbosity_Comparable = Verbosity | str | int 

326 

327DEFAULT_VERBOSITY = Verbosity.normal 

328 

329 

330class Format(enum.Enum): 

331 """ 

332 Options for su6 --format. 

333 """ 

334 

335 text = "text" 

336 json = "json" 

337 

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

339 """ 

340 Magic method for self == other. 

341 

342 'eq' is a special case because 'other' MUST be object according to mypy 

343 """ 

344 if other is Ellipsis or other is inspect._empty: 

345 # both instances of object; can't use Ellipsis or type(ELlipsis) = ellipsis as a type hint in mypy 

346 # special cases where Typer instanciates its cli arguments, 

347 # return False or it will crash 

348 return False 

349 return self.value == other 

350 

351 def __hash__(self) -> int: 

352 """ 

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

354 """ 

355 return hash(self.value) 

356 

357 

358DEFAULT_FORMAT = Format.text 

359 

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

361 

362DEFAULT_BADGE = "coverage.svg" 

363 

364 

365class AbstractConfig(configuraptor.TypedConfig, configuraptor.Singleton): 

366 """ 

367 Used by state.config and plugin configs. 

368 """ 

369 

370 _strict = True 

371 

372 

373@dataclass 

374class Config(AbstractConfig): 

375 """ 

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

377 

378 Also accessible via state.config 

379 """ 

380 

381 directory: str = "." 

382 pyproject: str = "pyproject.toml" 

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

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

385 stop_after_first_failure: bool = False 

386 json_indent: int = 4 

387 docstyle_convention: Optional[str] = None 

388 default_flags: typing.Optional[dict[str, str | list[str]]] = field(default=None) 

389 

390 ### pytest ### 

391 coverage: Optional[float] = None # only relevant for pytest 

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

393 

394 def __post_init__(self) -> None: 

395 """ 

396 Update the value of badge to the default path. 

397 """ 

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

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

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

401 self.badge = DEFAULT_BADGE 

402 

403 def determine_which_to_run(self, options: list[C], exclude: list[str] = None) -> list[C]: 

404 """ 

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

406 

407 `exclude` via cli overwrites config option. 

408 """ 

409 if self.include: 

410 tools = [_ for _ in options if _.__name__ in self.include and _.__name__ not in (exclude or [])] 

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

412 return tools 

413 elif self.exclude or exclude: 

414 to_exclude = set((self.exclude or []) + (exclude or [])) 

415 return [_ for _ in options if _.__name__ not in to_exclude] 

416 else: 

417 return options 

418 

419 def determine_plugins_to_run(self, attr: str, exclude: list[str] = None) -> list[T_Command]: 

420 """ 

421 Similar to `determine_which_to_run` but for plugin commands, and without 'include' ('exclude' only). 

422 

423 Attr is the key in Registration to filter plugins on, e.g. 'add_to_all' 

424 """ 

425 to_exclude = set((self.exclude or []) + (exclude or [])) 

426 

427 return [ 

428 _.wrapped for name, _ in state._registered_plugins.items() if getattr(_, attr) and name not in to_exclude 

429 ] 

430 

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

432 """ 

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

434 

435 Used to later look up Plugin config. 

436 """ 

437 self.__raw.update(raw) 

438 

439 def get_raw(self) -> dict[str, Any]: 

440 """ 

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

442 """ 

443 return self.__raw or {} 

444 

445 def get_default_flags(self, service: str) -> list[str]: 

446 """ 

447 For a given service, load the additional flags from pyproject.toml. 

448 

449 Example: 

450 [tool.su6.default-flags] 

451 mypy = "--disable-error-code misc" 

452 black = ["--include", "something", "--exclude", "something"] 

453 """ 

454 if not self.default_flags: 

455 return [] 

456 

457 flags = self.default_flags.get(service, []) 

458 if not flags: 

459 return [] 

460 

461 if isinstance(flags, list): 

462 return flags 

463 elif isinstance(flags, str): 

464 return [_.strip() for _ in flags.split(" ") if _.strip()] 

465 raise TypeError(f"Invalid type {type(flags)} for flags.") 

466 

467 

468MaybeConfig: TypeAlias = Optional[Config] 

469 

470T_typelike: TypeAlias = type | types.UnionType | types.UnionType 

471 

472 

473def _get_su6_config(overwrites: dict[str, Any], toml_path: Optional[str | Path] = 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 = find_pyproject_toml() 

487 

488 if not toml_path: 

489 return None 

490 

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

492 full_config = tomli.load(f) 

493 

494 tool_config = full_config["tool"] 

495 

496 config = configuraptor.load_into(Config, tool_config, key="su6") 

497 

498 config.update(pyproject=str(toml_path)) 

499 config.update(**overwrites) 

500 # for plugins: 

501 config.set_raw(tool_config["su6"]) 

502 

503 return config 

504 

505 

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

507 """ 

508 Load the relevant pyproject.toml config settings. 

509 

510 Args: 

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

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

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

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

515 """ 

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

517 overwrites = convert_config(overwrites) 

518 

519 try: 

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

521 return config 

522 raise ValueError("Falsey config?") 

523 except Exception as e: 

524 # something went wrong parsing config, use defaults 

525 if verbosity > 3: 

526 # verbosity = debug 

527 raise e 

528 elif verbosity > 2: 

529 # verbosity = verbose 

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

531 return Config(**overwrites) 

532 

533 

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

535 """ 

536 'print' but with blue text. 

537 """ 

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

539 

540 

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

542 """ 

543 'print' but with yellow text. 

544 """ 

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

546 

547 

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

549 """ 

550 'print' but with red text. 

551 """ 

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

553 

554 

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

556 """ 

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

558 """ 

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

560 

561 

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

563 """ 

564 Print stdout in yellow and stderr in red. 

565 """ 

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

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

568 warn(stdout) 

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

570 danger(stderr) 

571 

572 

573# postponed: use with Unpack later. 

574# class _Overwrites(typing.TypedDict, total=False): 

575# config_file: Optional[str] 

576# verbosity: Verbosity 

577# output_format: Format 

578# # + kwargs 

579 

580 

581@dataclass() 

582class ApplicationState: 

583 """ 

584 Application State - global user defined variables. 

585 

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

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

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

589 

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

591 """ 

592 

593 verbosity: Verbosity = DEFAULT_VERBOSITY 

594 output_format: Format = DEFAULT_FORMAT 

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

596 config: MaybeConfig = None 

597 

598 def __post_init__(self) -> None: 

599 """ 

600 Store registered plugin config. 

601 """ 

602 self._plugin_configs: dict[str, AbstractConfig] = {} 

603 self._registered_plugins: dict[str, "AnyRegistration"] = {} 

604 

605 def register_plugin(self, plugin_name: str, registration: "AnyRegistration") -> None: 

606 """ 

607 Connect a Registration to the State. 

608 

609 Used by `all` and `fix` to include plugin commands with add_to_all or add_to_fix respectively. 

610 """ 

611 plugin_name = plugin_name.replace("_", "-") 

612 self._registered_plugins[plugin_name] = registration 

613 

614 def load_config(self, **overwrites: Any) -> Config: 

615 """ 

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

617 

618 Also updates attached plugin configs. 

619 """ 

620 if "verbosity" in overwrites: 

621 self.verbosity = overwrites["verbosity"] 

622 if "config_file" in overwrites: 

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

624 if "output_format" in overwrites: 

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

626 

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

628 self._setup_plugin_config_defaults() 

629 return self.config 

630 

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

632 """ 

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

634 

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

636 """ 

637 self._plugin_configs[name] = config_cls 

638 

639 def _setup_plugin_config_defaults(self) -> None: 

640 """ 

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

642 """ 

643 config = self.get_config() 

644 raw = config.get_raw() 

645 for name, config_instance in self._plugin_configs.items(): 

646 configuraptor.load_into_instance(config_instance, raw, key=name, strict=config_instance._strict) 

647 

648 def get_config(self) -> Config: 

649 """ 

650 Get a filled config instance. 

651 """ 

652 return self.config or self.load_config() 

653 

654 def update_config(self, **values: Any) -> Config: 

655 """ 

656 Overwrite default/toml settings with cli values. 

657 

658 Example: 

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

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

661 """ 

662 existing_config = self.get_config() 

663 

664 values = convert_config(values) 

665 existing_config.update(**values) 

666 return existing_config 

667 

668 

669state = ApplicationState()