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

243 statements  

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

14import warnings 

15from dataclasses import dataclass, field 

16 

17import black.files 

18import configuraptor 

19import plumbum.commands.processes as pb 

20import typer 

21from plumbum import local 

22from plumbum.machines import LocalCommand 

23from rich import print 

24from typeguard import TypeCheckError 

25from typeguard import check_type as _check_type 

26 

27GREEN_CIRCLE = "🟢" 

28YELLOW_CIRCLE = "🟡" 

29RED_CIRCLE = "🔴" 

30 

31EXIT_CODE_SUCCESS = 0 

32EXIT_CODE_ERROR = 1 

33EXIT_CODE_COMMAND_NOT_FOUND = 127 

34 

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

36 

37# a Command can return these: 

38T_Command_Return = bool | int | None 

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

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

41T_Command: typing.TypeAlias = typing.Callable[..., T_Command_Return] 

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

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

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

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

46T_Outer_Wrapper: typing.TypeAlias = typing.Callable[[T_Command], T_Inner_Wrapper] 

47 

48 

49def print_json(data: typing.Any) -> None: 

50 """ 

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

52 """ 

53 print(json.dumps(data, default=str)) 

54 

55 

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

57 """ 

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

59 

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

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

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

63 

64 Args: 

65 tools: list of commands that ran 

66 results: list of return values from these commands 

67 """ 

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

69 

70 

71def with_exit_code() -> T_Outer_Wrapper: 

72 """ 

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

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

75 

76 Usage: 

77 > @app.command() 

78 > @with_exit_code() 

79 def some_command(): ... 

80 

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

82 """ 

83 

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

85 @functools.wraps(func) 

86 def inner_wrapper(*args: typing.Any, **kwargs: typing.Any) -> int: 

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

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

89 

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

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

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

93 # print {tool: success} 

94 # but only if a retcode is returned, 

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

96 dump_tools_with_results([func], [result]) 

97 

98 if result is None: 

99 # assume no issue then 

100 result = 0 

101 

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

103 raise typer.Exit(code=retcode) 

104 

105 if retcode in _ignore_exit_codes: # pragma: no cover 

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

107 return EXIT_CODE_SUCCESS 

108 

109 return retcode 

110 

111 return inner_wrapper 

112 

113 return outer_wrapper 

114 

115 

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

117 """ 

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

119 

120 Args: 

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

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

123 """ 

124 try: 

125 cmd = local[tool] 

126 

127 if state.verbosity >= 3: 

128 log_command(cmd, args) 

129 

130 result = cmd(*args) 

131 

132 if state.output_format == "text": 

133 print(GREEN_CIRCLE, tool) 

134 

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

136 log_cmd_output(result) 

137 

138 return EXIT_CODE_SUCCESS # success 

139 except pb.CommandNotFound: # pragma: no cover 

140 if state.verbosity > 2: 

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

142 

143 if state.output_format == "text": 

144 print(YELLOW_CIRCLE, tool) 

145 

146 return EXIT_CODE_COMMAND_NOT_FOUND # command not found 

147 except pb.ProcessExecutionError as e: 

148 if state.output_format == "text": 

149 print(RED_CIRCLE, tool) 

150 

151 if state.verbosity > 1: 

152 log_cmd_output(e.stdout, e.stderr) 

153 return EXIT_CODE_ERROR # general error 

154 

155 

156class Verbosity(enum.Enum): 

157 """ 

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

159 """ 

160 

161 # typer enum can only be string 

162 quiet = "1" 

163 normal = "2" 

164 verbose = "3" 

165 debug = "4" # only for internal use 

166 

167 @staticmethod 

168 def _compare( 

169 self: "Verbosity", 

170 other: "Verbosity_Comparable", 

171 _operator: typing.Callable[["Verbosity_Comparable", "Verbosity_Comparable"], bool], 

172 ) -> bool: 

173 """ 

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

175 

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

177 

178 Args: 

179 self: the first Verbosity 

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

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

182 """ 

183 match other: 

184 case Verbosity(): 

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

186 case int(): 

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

188 case str(): 

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

190 

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

192 """ 

193 Magic method for self > other. 

194 """ 

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

196 

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

198 """ 

199 Method magic for self >= other. 

200 """ 

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

202 

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

204 """ 

205 Magic method for self < other. 

206 """ 

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

208 

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

210 """ 

211 Magic method for self <= other. 

212 """ 

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

214 

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

216 """ 

217 Magic method for self == other. 

218 

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

220 """ 

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

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

223 # special cases where Typer instanciates its cli arguments, 

224 # return False or it will crash 

225 return False 

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

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

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

229 

230 def __hash__(self) -> int: 

231 """ 

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

233 """ 

234 return hash(self.value) 

235 

236 

237Verbosity_Comparable = Verbosity | str | int 

238 

239DEFAULT_VERBOSITY = Verbosity.normal 

240 

241 

242class Format(enum.Enum): 

243 """ 

244 Options for su6 --format. 

245 """ 

246 

247 text = "text" 

248 json = "json" 

249 

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

251 """ 

252 Magic method for self == other. 

253 

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

255 """ 

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

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

258 # special cases where Typer instanciates its cli arguments, 

259 # return False or it will crash 

260 return False 

261 return self.value == other 

262 

263 def __hash__(self) -> int: 

264 """ 

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

266 """ 

267 return hash(self.value) 

268 

269 

270DEFAULT_FORMAT = Format.text 

271 

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

273 

274DEFAULT_BADGE = "coverage.svg" 

275 

276 

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

278 """ 

279 Used by state.config and plugin configs. 

280 """ 

281 

282 _strict = True 

283 

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

285 """ 

286 Set the config values. 

287 

288 Args: 

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

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

291 """ 

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

293 if value is None and not allow_none: 

294 continue 

295 

296 if strict and key not in self.__annotations__: 

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

298 

299 setattr(self, key, value) 

300 

301 

302@dataclass 

303class Config(AbstractConfig): 

304 """ 

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

306 

307 Also accessible via state.config 

308 """ 

309 

310 directory: str = "." 

311 pyproject: str = "pyproject.toml" 

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

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

314 stop_after_first_failure: bool = False 

315 

316 ### pytest ### 

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

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

319 

320 def __post_init__(self) -> None: 

321 """ 

322 Update the value of badge to the default path. 

323 """ 

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

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

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

327 self.badge = DEFAULT_BADGE 

328 

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

330 """ 

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

332 """ 

333 if self.include: 

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

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

336 return tools 

337 elif self.exclude: 

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

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

340 return options 

341 

342 def set_raw(self, raw: dict[str, typing.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, typing.Any]: 

351 """ 

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

353 """ 

354 return self.__raw or {} 

355 

356 

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

358 

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

360 

361 

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

363 """ 

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

365 

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

367 """ 

368 try: 

369 _check_type(value, expected_type) 

370 return True 

371 except TypeCheckError: 

372 return False 

373 

374 

375@dataclass 

376class ConfigError(Exception): 

377 """ 

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

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

380 """ 

381 

382 key: str 

383 value: typing.Any 

384 expected_type: type 

385 

386 def __post_init__(self) -> None: 

387 """ 

388 Store the actual type of the config variable. 

389 """ 

390 self.actual_type = type(self.value) 

391 

392 def __str__(self) -> str: 

393 """ 

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

395 """ 

396 return ( 

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

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

399 ) 

400 

401 

402T = typing.TypeVar("T") 

403 

404 

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

406 """ 

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

408 

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

410 """ 

411 warnings.warn("`_ensure_types` Deprecated: should be replaced by configuraptor.") 

412 

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

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

415 compare = data.get(key) 

416 if compare is None: 

417 # skip! 

418 continue 

419 if not check_type(compare, _type): 

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

421 

422 final[key] = compare 

423 return final 

424 

425 

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

427 """ 

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

429 

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

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

432 """ 

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

434 

435 

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

437 """ 

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

439 

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

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

442 

443 Args: 

444 overwrites: cli arguments can overwrite the config toml. 

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

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

447 """ 

448 if toml_path is None: 

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

450 

451 if not toml_path: 

452 return None 

453 

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

455 full_config = tomllib.load(f) 

456 

457 tool_config = full_config["tool"] 

458 

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

460 

461 config.update(pyproject=toml_path) 

462 config.update(**overwrites) 

463 # for plugins: 

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

465 

466 return config 

467 

468 

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

470 """ 

471 Load the relevant pyproject.toml config settings. 

472 

473 Args: 

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

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

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

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

478 """ 

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

480 overwrites = _convert_config(overwrites) 

481 

482 try: 

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

484 return config 

485 raise ValueError("Falsey config?") 

486 except Exception as e: 

487 # something went wrong parsing config, use defaults 

488 if verbosity > 3: 

489 # verbosity = debug 

490 raise e 

491 elif verbosity > 2: 

492 # verbosity = verbose 

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

494 return Config(**overwrites) 

495 

496 

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

498 """ 

499 'print' but with blue text. 

500 """ 

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

502 

503 

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

505 """ 

506 'print' but with yellow text. 

507 """ 

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

509 

510 

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

512 """ 

513 'print' but with red text. 

514 """ 

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

516 

517 

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

519 """ 

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

521 """ 

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

523 

524 

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

526 """ 

527 Print stdout in yellow and stderr in red. 

528 """ 

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

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

531 warn(stdout) 

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

533 danger(stderr) 

534 

535 

536@dataclass() 

537class ApplicationState: 

538 """ 

539 Application State - global user defined variables. 

540 

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

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

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

544 

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

546 """ 

547 

548 verbosity: Verbosity = DEFAULT_VERBOSITY 

549 output_format: Format = DEFAULT_FORMAT 

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

551 config: MaybeConfig = None 

552 

553 def __post_init__(self) -> None: 

554 """ 

555 Store registered plugin config. 

556 """ 

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

558 

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

560 """ 

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

562 

563 Also updates attached plugin configs. 

564 """ 

565 if "verbosity" in overwrites: 

566 self.verbosity = overwrites["verbosity"] 

567 if "config_file" in overwrites: 

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

569 if "output_format" in overwrites: 

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

571 

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

573 self._setup_plugin_config_defaults() 

574 return self.config 

575 

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

577 """ 

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

579 

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

581 """ 

582 self._plugins[name] = config_cls 

583 

584 def _setup_plugin_config_defaults(self) -> None: 

585 """ 

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

587 """ 

588 config = self.get_config() 

589 raw = config.get_raw() 

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

591 configuraptor.load_into(config_instance, raw, key=name, strict=config_instance._strict) 

592 

593 def get_config(self) -> Config: 

594 """ 

595 Get a filled config instance. 

596 """ 

597 return self.config or self.load_config() 

598 

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

600 """ 

601 Overwrite default/toml settings with cli values. 

602 

603 Example: 

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

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

606 """ 

607 existing_config = self.get_config() 

608 

609 values = _convert_config(values) 

610 existing_config.update(**values) 

611 return existing_config 

612 

613 

614state = ApplicationState()