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

231 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-10-09 14:28 +0200

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 run_tool(tool: str, *_args: str) -> int: 

120 """ 

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

122 

123 Args: 

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

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

126 """ 

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

128 

129 args = list(_args) 

130 

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

132 args.extend(extra_flags) 

133 

134 try: 

135 cmd = local[tool] 

136 

137 if state.verbosity >= 3: 

138 log_command(cmd, args) 

139 

140 result = cmd(*args) 

141 

142 if state.output_format == "text": 

143 print(GREEN_CIRCLE, tool_name) 

144 

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

146 log_cmd_output(result) 

147 

148 return EXIT_CODE_SUCCESS # success 

149 except pb.CommandNotFound: # pragma: no cover 

150 if state.verbosity > 2: 

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

152 

153 if state.output_format == "text": 

154 print(YELLOW_CIRCLE, tool_name) 

155 

156 return EXIT_CODE_COMMAND_NOT_FOUND # command not found 

157 except pb.ProcessExecutionError as e: 

158 if state.output_format == "text": 

159 print(RED_CIRCLE, tool_name) 

160 

161 if state.verbosity > 1: 

162 log_cmd_output(e.stdout, e.stderr) 

163 return EXIT_CODE_ERROR # general error 

164 

165 

166class Verbosity(enum.Enum): 

167 """ 

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

169 """ 

170 

171 # typer enum can only be string 

172 quiet = "1" 

173 normal = "2" 

174 verbose = "3" 

175 debug = "4" # only for internal use 

176 

177 @staticmethod 

178 def _compare( 

179 self: "Verbosity", 

180 other: "Verbosity_Comparable", 

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

182 ) -> bool: 

183 """ 

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

185 

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

187 

188 Args: 

189 self: the first Verbosity 

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

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

192 """ 

193 match other: 

194 case Verbosity(): 

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

196 case int(): 

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

198 case str(): 

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

200 

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

202 """ 

203 Magic method for self > other. 

204 """ 

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

206 

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

208 """ 

209 Method magic for self >= other. 

210 """ 

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

212 

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

214 """ 

215 Magic method for self < other. 

216 """ 

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

218 

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

220 """ 

221 Magic method for self <= other. 

222 """ 

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

224 

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

226 """ 

227 Magic method for self == other. 

228 

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

230 """ 

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

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

233 # special cases where Typer instanciates its cli arguments, 

234 # return False or it will crash 

235 return False 

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

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

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

239 

240 def __hash__(self) -> int: 

241 """ 

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

243 """ 

244 return hash(self.value) 

245 

246 

247Verbosity_Comparable = Verbosity | str | int 

248 

249DEFAULT_VERBOSITY = Verbosity.normal 

250 

251 

252class Format(enum.Enum): 

253 """ 

254 Options for su6 --format. 

255 """ 

256 

257 text = "text" 

258 json = "json" 

259 

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

261 """ 

262 Magic method for self == other. 

263 

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

265 """ 

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

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

268 # special cases where Typer instanciates its cli arguments, 

269 # return False or it will crash 

270 return False 

271 return self.value == other 

272 

273 def __hash__(self) -> int: 

274 """ 

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

276 """ 

277 return hash(self.value) 

278 

279 

280DEFAULT_FORMAT = Format.text 

281 

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

283 

284DEFAULT_BADGE = "coverage.svg" 

285 

286 

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

288 """ 

289 Used by state.config and plugin configs. 

290 """ 

291 

292 _strict = True 

293 

294 

295@dataclass 

296class Config(AbstractConfig): 

297 """ 

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

299 

300 Also accessible via state.config 

301 """ 

302 

303 directory: str = "." 

304 pyproject: str = "pyproject.toml" 

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

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

307 stop_after_first_failure: bool = False 

308 json_indent: int = 4 

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

310 

311 ### pytest ### 

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

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

314 

315 def __post_init__(self) -> None: 

316 """ 

317 Update the value of badge to the default path. 

318 """ 

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

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

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

322 self.badge = DEFAULT_BADGE 

323 

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

325 """ 

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

327 

328 `exclude` via cli overwrites config option. 

329 """ 

330 if self.include: 

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

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

333 return tools 

334 elif self.exclude or exclude: 

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

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

337 else: 

338 return options 

339 

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

341 """ 

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

343 

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

345 """ 

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

347 

348 return [ 

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

350 ] 

351 

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

353 """ 

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

355 

356 Used to later look up Plugin config. 

357 """ 

358 self.__raw.update(raw) 

359 

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

361 """ 

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

363 """ 

364 return self.__raw or {} 

365 

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

367 """ 

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

369 

370 Example: 

371 [tool.su6.default-flags] 

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

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

374 """ 

375 if not self.default_flags: 

376 return [] 

377 

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

379 if not flags: 

380 return [] 

381 

382 if isinstance(flags, list): 

383 return flags 

384 elif isinstance(flags, str): 

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

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

387 

388 

389MaybeConfig: TypeAlias = Optional[Config] 

390 

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

392 

393 

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

395 """ 

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

397 

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

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

400 

401 Args: 

402 overwrites: cli arguments can overwrite the config toml. 

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

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

405 """ 

406 if toml_path is None: 

407 toml_path = find_pyproject_toml() 

408 

409 if not toml_path: 

410 return None 

411 

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

413 full_config = tomli.load(f) 

414 

415 tool_config = full_config["tool"] 

416 

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

418 

419 config.update(pyproject=toml_path) 

420 config.update(**overwrites) 

421 # for plugins: 

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

423 

424 return config 

425 

426 

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

428 """ 

429 Load the relevant pyproject.toml config settings. 

430 

431 Args: 

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

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

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

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

436 """ 

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

438 overwrites = convert_config(overwrites) 

439 

440 try: 

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

442 return config 

443 raise ValueError("Falsey config?") 

444 except Exception as e: 

445 # something went wrong parsing config, use defaults 

446 if verbosity > 3: 

447 # verbosity = debug 

448 raise e 

449 elif verbosity > 2: 

450 # verbosity = verbose 

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

452 return Config(**overwrites) 

453 

454 

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

456 """ 

457 'print' but with blue text. 

458 """ 

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

460 

461 

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

463 """ 

464 'print' but with yellow text. 

465 """ 

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

467 

468 

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

470 """ 

471 'print' but with red text. 

472 """ 

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

474 

475 

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

477 """ 

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

479 """ 

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

481 

482 

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

484 """ 

485 Print stdout in yellow and stderr in red. 

486 """ 

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

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

489 warn(stdout) 

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

491 danger(stderr) 

492 

493 

494# postponed: use with Unpack later. 

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

496# config_file: Optional[str] 

497# verbosity: Verbosity 

498# output_format: Format 

499# # + kwargs 

500 

501 

502@dataclass() 

503class ApplicationState: 

504 """ 

505 Application State - global user defined variables. 

506 

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

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

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

510 

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

512 """ 

513 

514 verbosity: Verbosity = DEFAULT_VERBOSITY 

515 output_format: Format = DEFAULT_FORMAT 

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

517 config: MaybeConfig = None 

518 

519 def __post_init__(self) -> None: 

520 """ 

521 Store registered plugin config. 

522 """ 

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

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

525 

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

527 """ 

528 Connect a Registration to the State. 

529 

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

531 """ 

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

533 self._registered_plugins[plugin_name] = registration 

534 

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

536 """ 

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

538 

539 Also updates attached plugin configs. 

540 """ 

541 if "verbosity" in overwrites: 

542 self.verbosity = overwrites["verbosity"] 

543 if "config_file" in overwrites: 

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

545 if "output_format" in overwrites: 

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

547 

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

549 self._setup_plugin_config_defaults() 

550 return self.config 

551 

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

553 """ 

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

555 

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

557 """ 

558 self._plugin_configs[name] = config_cls 

559 

560 def _setup_plugin_config_defaults(self) -> None: 

561 """ 

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

563 """ 

564 config = self.get_config() 

565 raw = config.get_raw() 

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

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

568 

569 def get_config(self) -> Config: 

570 """ 

571 Get a filled config instance. 

572 """ 

573 return self.config or self.load_config() 

574 

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

576 """ 

577 Overwrite default/toml settings with cli values. 

578 

579 Example: 

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

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

582 """ 

583 existing_config = self.get_config() 

584 

585 values = convert_config(values) 

586 existing_config.update(**values) 

587 return existing_config 

588 

589 

590state = ApplicationState()