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

237 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2024-03-04 18:09 +0100

1""" 

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

3""" 

4import enum 

5import functools 

6import inspect 

7import json 

8import operator 

9import sys 

10import types 

11import typing 

12from dataclasses import dataclass, field 

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

14 

15import configuraptor 

16import plumbum.commands.processes as pb 

17import tomli 

18import typer 

19from configuraptor import convert_config 

20from configuraptor.helpers import find_pyproject_toml 

21from plumbum import local 

22from plumbum.machines import LocalCommand 

23from rich import print 

24 

25if typing.TYPE_CHECKING: # pragma: no cover 

26 from .plugins import AnyRegistration 

27 

28GREEN_CIRCLE = "🟢" 

29YELLOW_CIRCLE = "🟡" 

30RED_CIRCLE = "🔴" 

31 

32EXIT_CODE_SUCCESS = 0 

33EXIT_CODE_ERROR = 1 

34EXIT_CODE_COMMAND_NOT_FOUND = 127 

35 

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

37 

38# a Command can return these: 

39T_Command_Return = bool | int | None 

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

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

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

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

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

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

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

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

48 

49 

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

51 """ 

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

53 """ 

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

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

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

57 

58 

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

60 """ 

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

62 

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

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

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

66 

67 Args: 

68 tools: list of commands that ran 

69 results: list of return values from these commands 

70 """ 

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

72 

73 

74def with_exit_code() -> T_Outer_Wrapper: 

75 """ 

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

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

78 

79 Usage: 

80 > @app.command() 

81 > @with_exit_code() 

82 def some_command(): ... 

83 

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

85 """ 

86 

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

88 @functools.wraps(func) 

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

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

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

92 

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

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

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

96 # print {tool: success} 

97 # but only if a retcode is returned, 

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

99 dump_tools_with_results([func], [result]) 

100 

101 if result is None: 

102 # assume no issue then 

103 result = 0 

104 

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

106 raise typer.Exit(code=retcode) 

107 

108 if retcode in _ignore_exit_codes: # pragma: no cover 

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

110 return EXIT_CODE_SUCCESS 

111 

112 return retcode 

113 

114 return inner_wrapper 

115 

116 return outer_wrapper 

117 

118 

119def is_installed(tool: str) -> bool: 

120 """ 

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

122 """ 

123 try: 

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

125 except pb.ProcessExecutionError: 

126 return False 

127 

128 

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

130 """ 

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

132 

133 Args: 

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

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

136 """ 

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

138 

139 args = list(_args) 

140 

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

142 args.extend(extra_flags) 

143 

144 try: 

145 cmd = local[tool] 

146 

147 if state.verbosity >= 3: 

148 log_command(cmd, args) 

149 

150 result = cmd(*args) 

151 

152 if state.output_format == "text": 

153 print(GREEN_CIRCLE, tool_name) 

154 

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

156 log_cmd_output(result) 

157 

158 return EXIT_CODE_SUCCESS # success 

159 except pb.CommandNotFound: # pragma: no cover 

160 if state.verbosity > 2: 

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

162 

163 if state.output_format == "text": 

164 print(YELLOW_CIRCLE, tool_name) 

165 

166 return EXIT_CODE_COMMAND_NOT_FOUND # command not found 

167 except pb.ProcessExecutionError as e: 

168 if state.output_format == "text": 

169 print(RED_CIRCLE, tool_name) 

170 

171 if state.verbosity > 1: 

172 log_cmd_output(e.stdout, e.stderr) 

173 return EXIT_CODE_ERROR # general error 

174 

175 

176class Verbosity(enum.Enum): 

177 """ 

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

179 """ 

180 

181 # typer enum can only be string 

182 quiet = "1" 

183 normal = "2" 

184 verbose = "3" 

185 debug = "4" # only for internal use 

186 

187 @staticmethod 

188 def _compare( 

189 self: "Verbosity", 

190 other: "Verbosity_Comparable", 

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

192 ) -> bool: 

193 """ 

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

195 

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

197 

198 Args: 

199 self: the first Verbosity 

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

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

202 """ 

203 match other: 

204 case Verbosity(): 

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

206 case int(): 

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

208 case str(): 

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

210 

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

212 """ 

213 Magic method for self > other. 

214 """ 

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

216 

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

218 """ 

219 Method magic for self >= other. 

220 """ 

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

222 

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

224 """ 

225 Magic method for self < other. 

226 """ 

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

228 

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

230 """ 

231 Magic method for self <= other. 

232 """ 

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

234 

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

236 """ 

237 Magic method for self == other. 

238 

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

240 """ 

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

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

243 # special cases where Typer instanciates its cli arguments, 

244 # return False or it will crash 

245 return False 

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

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

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

249 

250 def __hash__(self) -> int: 

251 """ 

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

253 """ 

254 return hash(self.value) 

255 

256 

257Verbosity_Comparable = Verbosity | str | int 

258 

259DEFAULT_VERBOSITY = Verbosity.normal 

260 

261 

262class Format(enum.Enum): 

263 """ 

264 Options for su6 --format. 

265 """ 

266 

267 text = "text" 

268 json = "json" 

269 

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

271 """ 

272 Magic method for self == other. 

273 

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

275 """ 

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

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

278 # special cases where Typer instanciates its cli arguments, 

279 # return False or it will crash 

280 return False 

281 return self.value == other 

282 

283 def __hash__(self) -> int: 

284 """ 

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

286 """ 

287 return hash(self.value) 

288 

289 

290DEFAULT_FORMAT = Format.text 

291 

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

293 

294DEFAULT_BADGE = "coverage.svg" 

295 

296 

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

298 """ 

299 Used by state.config and plugin configs. 

300 """ 

301 

302 _strict = True 

303 

304 

305@dataclass 

306class Config(AbstractConfig): 

307 """ 

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

309 

310 Also accessible via state.config 

311 """ 

312 

313 directory: str = "." 

314 pyproject: str = "pyproject.toml" 

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

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

317 stop_after_first_failure: bool = False 

318 json_indent: int = 4 

319 docstyle_convention: Optional[str] = None 

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

321 

322 ### pytest ### 

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

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

325 

326 def __post_init__(self) -> None: 

327 """ 

328 Update the value of badge to the default path. 

329 """ 

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

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

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

333 self.badge = DEFAULT_BADGE 

334 

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

336 """ 

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

338 

339 `exclude` via cli overwrites config option. 

340 """ 

341 if self.include: 

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

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

344 return tools 

345 elif self.exclude or exclude: 

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

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

348 else: 

349 return options 

350 

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

352 """ 

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

354 

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

356 """ 

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

358 

359 return [ 

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

361 ] 

362 

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

364 """ 

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

366 

367 Used to later look up Plugin config. 

368 """ 

369 self.__raw.update(raw) 

370 

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

372 """ 

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

374 """ 

375 return self.__raw or {} 

376 

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

378 """ 

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

380 

381 Example: 

382 [tool.su6.default-flags] 

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

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

385 """ 

386 if not self.default_flags: 

387 return [] 

388 

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

390 if not flags: 

391 return [] 

392 

393 if isinstance(flags, list): 

394 return flags 

395 elif isinstance(flags, str): 

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

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

398 

399 

400MaybeConfig: TypeAlias = Optional[Config] 

401 

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

403 

404 

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

406 """ 

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

408 

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

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

411 

412 Args: 

413 overwrites: cli arguments can overwrite the config toml. 

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

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

416 """ 

417 if toml_path is None: 

418 toml_path = find_pyproject_toml() 

419 

420 if not toml_path: 

421 return None 

422 

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

424 full_config = tomli.load(f) 

425 

426 tool_config = full_config["tool"] 

427 

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

429 

430 config.update(pyproject=toml_path) 

431 config.update(**overwrites) 

432 # for plugins: 

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

434 

435 return config 

436 

437 

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

439 """ 

440 Load the relevant pyproject.toml config settings. 

441 

442 Args: 

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

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

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

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

447 """ 

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

449 overwrites = convert_config(overwrites) 

450 

451 try: 

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

453 return config 

454 raise ValueError("Falsey config?") 

455 except Exception as e: 

456 # something went wrong parsing config, use defaults 

457 if verbosity > 3: 

458 # verbosity = debug 

459 raise e 

460 elif verbosity > 2: 

461 # verbosity = verbose 

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

463 return Config(**overwrites) 

464 

465 

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

467 """ 

468 'print' but with blue text. 

469 """ 

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

471 

472 

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

474 """ 

475 'print' but with yellow text. 

476 """ 

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

478 

479 

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

481 """ 

482 'print' but with red text. 

483 """ 

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

485 

486 

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

488 """ 

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

490 """ 

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

492 

493 

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

495 """ 

496 Print stdout in yellow and stderr in red. 

497 """ 

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

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

500 warn(stdout) 

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

502 danger(stderr) 

503 

504 

505# postponed: use with Unpack later. 

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

507# config_file: Optional[str] 

508# verbosity: Verbosity 

509# output_format: Format 

510# # + kwargs 

511 

512 

513@dataclass() 

514class ApplicationState: 

515 """ 

516 Application State - global user defined variables. 

517 

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

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

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

521 

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

523 """ 

524 

525 verbosity: Verbosity = DEFAULT_VERBOSITY 

526 output_format: Format = DEFAULT_FORMAT 

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

528 config: MaybeConfig = None 

529 

530 def __post_init__(self) -> None: 

531 """ 

532 Store registered plugin config. 

533 """ 

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

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

536 

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

538 """ 

539 Connect a Registration to the State. 

540 

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

542 """ 

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

544 self._registered_plugins[plugin_name] = registration 

545 

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

547 """ 

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

549 

550 Also updates attached plugin configs. 

551 """ 

552 if "verbosity" in overwrites: 

553 self.verbosity = overwrites["verbosity"] 

554 if "config_file" in overwrites: 

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

556 if "output_format" in overwrites: 

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

558 

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

560 self._setup_plugin_config_defaults() 

561 return self.config 

562 

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

564 """ 

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

566 

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

568 """ 

569 self._plugin_configs[name] = config_cls 

570 

571 def _setup_plugin_config_defaults(self) -> None: 

572 """ 

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

574 """ 

575 config = self.get_config() 

576 raw = config.get_raw() 

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

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

579 

580 def get_config(self) -> Config: 

581 """ 

582 Get a filled config instance. 

583 """ 

584 return self.config or self.load_config() 

585 

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

587 """ 

588 Overwrite default/toml settings with cli values. 

589 

590 Example: 

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

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

593 """ 

594 existing_config = self.get_config() 

595 

596 values = convert_config(values) 

597 existing_config.update(**values) 

598 return existing_config 

599 

600 

601state = ApplicationState()