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

216 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-07-17 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 types 

12import typing 

13from dataclasses import dataclass, field 

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

15 

16import black.files 

17import configuraptor 

18import plumbum.commands.processes as pb 

19import tomli 

20import typer 

21from configuraptor import convert_config 

22from plumbum import local 

23from plumbum.machines import LocalCommand 

24from rich import print 

25 

26if typing.TYPE_CHECKING: # pragma: no cover 

27 from .plugins import AnyRegistration 

28 

29GREEN_CIRCLE = "🟢" 

30YELLOW_CIRCLE = "🟡" 

31RED_CIRCLE = "🔴" 

32 

33EXIT_CODE_SUCCESS = 0 

34EXIT_CODE_ERROR = 1 

35EXIT_CODE_COMMAND_NOT_FOUND = 127 

36 

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

38 

39# a Command can return these: 

40T_Command_Return = bool | int | None 

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

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

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

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

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

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

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

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

49 

50 

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

52 """ 

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

54 """ 

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

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

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

58 

59 

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

61 """ 

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

63 

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

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

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

67 

68 Args: 

69 tools: list of commands that ran 

70 results: list of return values from these commands 

71 """ 

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

73 

74 

75def with_exit_code() -> T_Outer_Wrapper: 

76 """ 

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

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

79 

80 Usage: 

81 > @app.command() 

82 > @with_exit_code() 

83 def some_command(): ... 

84 

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

86 """ 

87 

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

89 @functools.wraps(func) 

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

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

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

93 

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

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

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

97 # print {tool: success} 

98 # but only if a retcode is returned, 

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

100 dump_tools_with_results([func], [result]) 

101 

102 if result is None: 

103 # assume no issue then 

104 result = 0 

105 

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

107 raise typer.Exit(code=retcode) 

108 

109 if retcode in _ignore_exit_codes: # pragma: no cover 

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

111 return EXIT_CODE_SUCCESS 

112 

113 return retcode 

114 

115 return inner_wrapper 

116 

117 return outer_wrapper 

118 

119 

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

121 """ 

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

123 

124 Args: 

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

126 args: cli args to pass to the cli bash tool 

127 """ 

128 try: 

129 cmd = local[tool] 

130 

131 if state.verbosity >= 3: 

132 log_command(cmd, args) 

133 

134 result = cmd(*args) 

135 

136 if state.output_format == "text": 

137 print(GREEN_CIRCLE, tool) 

138 

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

140 log_cmd_output(result) 

141 

142 return EXIT_CODE_SUCCESS # success 

143 except pb.CommandNotFound: # pragma: no cover 

144 if state.verbosity > 2: 

145 warn(f"Tool {tool} not installed!") 

146 

147 if state.output_format == "text": 

148 print(YELLOW_CIRCLE, tool) 

149 

150 return EXIT_CODE_COMMAND_NOT_FOUND # command not found 

151 except pb.ProcessExecutionError as e: 

152 if state.output_format == "text": 

153 print(RED_CIRCLE, tool) 

154 

155 if state.verbosity > 1: 

156 log_cmd_output(e.stdout, e.stderr) 

157 return EXIT_CODE_ERROR # general error 

158 

159 

160class Verbosity(enum.Enum): 

161 """ 

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

163 """ 

164 

165 # typer enum can only be string 

166 quiet = "1" 

167 normal = "2" 

168 verbose = "3" 

169 debug = "4" # only for internal use 

170 

171 @staticmethod 

172 def _compare( 

173 self: "Verbosity", 

174 other: "Verbosity_Comparable", 

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

176 ) -> bool: 

177 """ 

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

179 

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

181 

182 Args: 

183 self: the first Verbosity 

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

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

186 """ 

187 match other: 

188 case Verbosity(): 

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

190 case int(): 

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

192 case str(): 

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

194 

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

196 """ 

197 Magic method for self > other. 

198 """ 

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

200 

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

202 """ 

203 Method magic for self >= other. 

204 """ 

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

206 

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

208 """ 

209 Magic method for self < other. 

210 """ 

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

212 

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

214 """ 

215 Magic method for self <= other. 

216 """ 

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

218 

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

220 """ 

221 Magic method for self == other. 

222 

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

224 """ 

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

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

227 # special cases where Typer instanciates its cli arguments, 

228 # return False or it will crash 

229 return False 

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

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

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

233 

234 def __hash__(self) -> int: 

235 """ 

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

237 """ 

238 return hash(self.value) 

239 

240 

241Verbosity_Comparable = Verbosity | str | int 

242 

243DEFAULT_VERBOSITY = Verbosity.normal 

244 

245 

246class Format(enum.Enum): 

247 """ 

248 Options for su6 --format. 

249 """ 

250 

251 text = "text" 

252 json = "json" 

253 

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

255 """ 

256 Magic method for self == other. 

257 

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

259 """ 

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

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

262 # special cases where Typer instanciates its cli arguments, 

263 # return False or it will crash 

264 return False 

265 return self.value == other 

266 

267 def __hash__(self) -> int: 

268 """ 

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

270 """ 

271 return hash(self.value) 

272 

273 

274DEFAULT_FORMAT = Format.text 

275 

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

277 

278DEFAULT_BADGE = "coverage.svg" 

279 

280 

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

282 """ 

283 Used by state.config and plugin configs. 

284 """ 

285 

286 _strict = True 

287 

288 

289@dataclass 

290class Config(AbstractConfig): 

291 """ 

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

293 

294 Also accessible via state.config 

295 """ 

296 

297 directory: str = "." 

298 pyproject: str = "pyproject.toml" 

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

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

301 stop_after_first_failure: bool = False 

302 json_indent: int = 4 

303 

304 ### pytest ### 

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

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

307 

308 def __post_init__(self) -> None: 

309 """ 

310 Update the value of badge to the default path. 

311 """ 

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

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

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

315 self.badge = DEFAULT_BADGE 

316 

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

318 """ 

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

320 """ 

321 if self.include: 

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

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

324 return tools 

325 elif self.exclude: 

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

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

328 return options 

329 

330 def determine_plugins_to_run(self, attr: str) -> list[T_Command]: 

331 """ 

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

333 

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

335 """ 

336 return [ 

337 _.wrapped 

338 for name, _ in state._registered_plugins.items() 

339 if getattr(_, attr) and name not in (self.exclude or ()) 

340 ] 

341 

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

343 """ 

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

345 

346 Used to later look up Plugin config. 

347 """ 

348 self.__raw.update(raw) 

349 

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

351 """ 

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

353 """ 

354 return self.__raw or {} 

355 

356 

357MaybeConfig: TypeAlias = Optional[Config] 

358 

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

360 

361 

362def find_pyproject_toml() -> Optional[str]: 

363 """ 

364 Find the project's config toml, looks up until it finds the project root (black's logic). 

365 """ 

366 return black.files.find_pyproject_toml((os.getcwd(),)) 

367 

368 

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

370 """ 

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

372 

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

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

375 

376 Args: 

377 overwrites: cli arguments can overwrite the config toml. 

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

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

380 """ 

381 if toml_path is None: 

382 toml_path = find_pyproject_toml() 

383 

384 if not toml_path: 

385 return None 

386 

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

388 full_config = tomli.load(f) 

389 

390 tool_config = full_config["tool"] 

391 

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

393 

394 config.update(pyproject=toml_path) 

395 config.update(**overwrites) 

396 # for plugins: 

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

398 

399 return config 

400 

401 

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

403 """ 

404 Load the relevant pyproject.toml config settings. 

405 

406 Args: 

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

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

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

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

411 """ 

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

413 overwrites = convert_config(overwrites) 

414 

415 try: 

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

417 return config 

418 raise ValueError("Falsey config?") 

419 except Exception as e: 

420 # something went wrong parsing config, use defaults 

421 if verbosity > 3: 

422 # verbosity = debug 

423 raise e 

424 elif verbosity > 2: 

425 # verbosity = verbose 

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

427 return Config(**overwrites) 

428 

429 

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

431 """ 

432 'print' but with blue text. 

433 """ 

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

435 

436 

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

438 """ 

439 'print' but with yellow text. 

440 """ 

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

442 

443 

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

445 """ 

446 'print' but with red text. 

447 """ 

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

449 

450 

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

452 """ 

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

454 """ 

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

456 

457 

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

459 """ 

460 Print stdout in yellow and stderr in red. 

461 """ 

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

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

464 warn(stdout) 

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

466 danger(stderr) 

467 

468 

469# postponed: use with Unpack later. 

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

471# config_file: Optional[str] 

472# verbosity: Verbosity 

473# output_format: Format 

474# # + kwargs 

475 

476 

477@dataclass() 

478class ApplicationState: 

479 """ 

480 Application State - global user defined variables. 

481 

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

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

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

485 

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

487 """ 

488 

489 verbosity: Verbosity = DEFAULT_VERBOSITY 

490 output_format: Format = DEFAULT_FORMAT 

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

492 config: MaybeConfig = None 

493 

494 def __post_init__(self) -> None: 

495 """ 

496 Store registered plugin config. 

497 """ 

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

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

500 

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

502 """ 

503 Connect a Registration to the State. 

504 

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

506 """ 

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

508 self._registered_plugins[plugin_name] = registration 

509 

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

511 """ 

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

513 

514 Also updates attached plugin configs. 

515 """ 

516 if "verbosity" in overwrites: 

517 self.verbosity = overwrites["verbosity"] 

518 if "config_file" in overwrites: 

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

520 if "output_format" in overwrites: 

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

522 

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

524 self._setup_plugin_config_defaults() 

525 return self.config 

526 

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

528 """ 

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

530 

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

532 """ 

533 self._plugin_configs[name] = config_cls 

534 

535 def _setup_plugin_config_defaults(self) -> None: 

536 """ 

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

538 """ 

539 config = self.get_config() 

540 raw = config.get_raw() 

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

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

543 

544 def get_config(self) -> Config: 

545 """ 

546 Get a filled config instance. 

547 """ 

548 return self.config or self.load_config() 

549 

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

551 """ 

552 Overwrite default/toml settings with cli values. 

553 

554 Example: 

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

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

557 """ 

558 existing_config = self.get_config() 

559 

560 values = convert_config(values) 

561 existing_config.update(**values) 

562 return existing_config 

563 

564 

565state = ApplicationState()