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

188 statements  

« prev     ^ index     » next       coverage.py v7.2.6, created at 2023-05-30 14:21 +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 

14from dataclasses import dataclass, replace 

15 

16import black.files 

17import typer 

18from plumbum.machines import LocalCommand 

19from rich import print 

20from typeguard import TypeCheckError 

21from typeguard import check_type as _check_type 

22 

23GREEN_CIRCLE = "🟢" 

24YELLOW_CIRCLE = "🟡" 

25RED_CIRCLE = "🔴" 

26 

27EXIT_CODE_SUCCESS = 0 

28EXIT_CODE_ERROR = 1 

29EXIT_CODE_COMMAND_NOT_FOUND = 127 

30 

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

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

33T_Command: typing.TypeAlias = typing.Callable[..., bool | int] 

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

35T_Inner_Wrapper: typing.TypeAlias = typing.Callable[..., int] 

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

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

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

39 

40 

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

42 """ 

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

44 

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

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

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

48 

49 Args: 

50 tools: list of commands that ran 

51 results: list of return values from these commands 

52 """ 

53 text = json.dumps({tool.__name__: not result for tool, result in zip(tools, results)}) 

54 print(text) 

55 

56 

57def with_exit_code() -> T_Outer_Wrapper: 

58 """ 

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

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

61 

62 Usage: 

63 > @app.command() 

64 > @with_exit_code() 

65 def some_command(): ... 

66 

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

68 """ 

69 

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

71 @functools.wraps(func) 

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

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

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

75 

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

77 if state.format == "json" and not _suppress and not isinstance(result, bool): 

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

79 # print {tool: success} 

80 # but only if a retcode is returned, 

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

82 dump_tools_with_results([func], [result]) 

83 

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

85 raise typer.Exit(code=retcode) 

86 

87 if retcode in _ignore_exit_codes: # pragma: no cover 

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

89 return EXIT_CODE_SUCCESS 

90 

91 return retcode 

92 

93 return inner_wrapper 

94 

95 return outer_wrapper 

96 

97 

98class Verbosity(enum.Enum): 

99 """ 

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

101 """ 

102 

103 # typer enum can only be string 

104 quiet = "1" 

105 normal = "2" 

106 verbose = "3" 

107 debug = "4" # only for internal use 

108 

109 @staticmethod 

110 def _compare( 

111 self: "Verbosity", 

112 other: "Verbosity_Comparable", 

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

114 ) -> bool: 

115 """ 

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

117 

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

119 

120 Args: 

121 self: the first Verbosity 

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

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

124 """ 

125 match other: 

126 case Verbosity(): 

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

128 case int(): 

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

130 case str(): 

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

132 

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

134 """ 

135 Magic method for self > other. 

136 """ 

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

138 

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

140 """ 

141 Method magic for self >= other. 

142 """ 

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

144 

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

146 """ 

147 Magic method for self < other. 

148 """ 

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

150 

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

152 """ 

153 Magic method for self <= other. 

154 """ 

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

156 

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

158 """ 

159 Magic method for self == other. 

160 

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

162 """ 

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

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

165 # special cases where Typer instanciates its cli arguments, 

166 # return False or it will crash 

167 return False 

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

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

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

171 

172 def __hash__(self) -> int: 

173 """ 

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

175 """ 

176 return hash(self.value) 

177 

178 

179Verbosity_Comparable = Verbosity | str | int 

180 

181DEFAULT_VERBOSITY = Verbosity.normal 

182 

183 

184class Format(enum.Enum): 

185 """ 

186 Options for su6 --format. 

187 """ 

188 

189 text = "text" 

190 json = "json" 

191 

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

193 """ 

194 Magic method for self == other. 

195 

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

197 """ 

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

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

200 # special cases where Typer instanciates its cli arguments, 

201 # return False or it will crash 

202 return False 

203 return self.value == other 

204 

205 def __hash__(self) -> int: 

206 """ 

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

208 """ 

209 return hash(self.value) 

210 

211 

212DEFAULT_FORMAT = Format.text 

213 

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

215 

216DEFAULT_BADGE = "coverage.svg" 

217 

218 

219@dataclass 

220class Config: 

221 """ 

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

223 

224 Also accessible via state.config 

225 """ 

226 

227 directory: str = "." 

228 pyproject: str = "pyproject.toml" 

229 include: typing.Optional[list[str]] = None 

230 exclude: typing.Optional[list[str]] = None 

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

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

233 

234 def __post_init__(self) -> None: 

235 """ 

236 Update the value of badge to the default path. 

237 """ 

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

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

240 self.badge = DEFAULT_BADGE 

241 

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

243 """ 

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

245 """ 

246 if self.include: 

247 return [_ for _ in options if _.__name__ in self.include] 

248 elif self.exclude: 

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

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

251 return options 

252 

253 

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

255 

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

257 

258 

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

260 """ 

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

262 

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

264 """ 

265 try: 

266 _check_type(value, expected_type) 

267 return True 

268 except TypeCheckError: 

269 return False 

270 

271 

272@dataclass 

273class ConfigError(Exception): 

274 """ 

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

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

277 """ 

278 

279 key: str 

280 value: typing.Any 

281 expected_type: type 

282 

283 def __post_init__(self) -> None: 

284 """ 

285 Store the actual type of the config variable. 

286 """ 

287 self.actual_type = type(self.value) 

288 

289 def __str__(self) -> str: 

290 """ 

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

292 """ 

293 return ( 

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

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

296 ) 

297 

298 

299T = typing.TypeVar("T") 

300 

301 

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

303 """ 

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

305 

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

307 """ 

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

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

310 compare = data.get(key) 

311 if compare is None: 

312 # skip! 

313 continue 

314 if not check_type(compare, _type): 

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

316 

317 final[key] = compare 

318 return final 

319 

320 

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

322 """ 

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

324 

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

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

327 

328 Args: 

329 overwrites: cli arguments can overwrite the config toml. 

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

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

332 """ 

333 if toml_path is None: 

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

335 

336 if not toml_path: 

337 return None 

338 

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

340 full_config = tomllib.load(f) 

341 

342 su6_config_dict = full_config["tool"]["su6"] 

343 su6_config_dict |= overwrites 

344 

345 su6_config_dict["pyproject"] = toml_path 

346 su6_config_dict = _ensure_types(su6_config_dict, Config.__annotations__) 

347 

348 return Config(**su6_config_dict) 

349 

350 

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

352 """ 

353 Load the relevant pyproject.toml config settings. 

354 

355 Args: 

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

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

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

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

360 """ 

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

362 overwrites = {k: v for k, v in overwrites.items() if v is not None} 

363 

364 try: 

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

366 return config 

367 raise ValueError("Falsey config?") 

368 except Exception as e: 

369 # something went wrong parsing config, use defaults 

370 if verbosity > 3: 

371 # verbosity = debug 

372 raise e 

373 elif verbosity > 2: 

374 # verbosity = verbose 

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

376 return Config(**overwrites) 

377 

378 

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

380 """ 

381 'print' but with blue text. 

382 """ 

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

384 

385 

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

387 """ 

388 'print' but with yellow text. 

389 """ 

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

391 

392 

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

394 """ 

395 'print' but with red text. 

396 """ 

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

398 

399 

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

401 """ 

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

403 """ 

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

405 

406 

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

408 """ 

409 Print stdout in yellow and stderr in red. 

410 """ 

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

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

413 warn(stdout) 

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

415 danger(stderr) 

416 

417 

418@dataclass() 

419class ApplicationState: 

420 """ 

421 Application State - global user defined variables. 

422 

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

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

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

426 """ 

427 

428 verbosity: Verbosity = DEFAULT_VERBOSITY 

429 format: Format = DEFAULT_FORMAT 

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

431 config: MaybeConfig = None 

432 

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

434 """ 

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

436 """ 

437 if "verbosity" in overwrites: 

438 self.verbosity = overwrites["verbosity"] 

439 if "config_file" in overwrites: 

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

441 if "format" in overwrites: 

442 self.format = overwrites.pop("format") 

443 

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

445 return self.config 

446 

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

448 """ 

449 Overwrite default/toml settings with cli values. 

450 

451 Example: 

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

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

454 """ 

455 if self.config is None: 

456 # not loaded yet! 

457 existing_config = self.load_config() 

458 else: 

459 existing_config = self.config 

460 

461 values = {k: v for k, v in values.items() if v is not None} 

462 # replace is dataclass' update function 

463 self.config = replace(existing_config, **values) 

464 return self.config 

465 

466 

467state = ApplicationState()