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

189 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-05-31 19:09 +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 plumbum.commands.processes as pb 

18import typer 

19from plumbum.machines import LocalCommand 

20from rich import print 

21from typeguard import TypeCheckError 

22from typeguard import check_type as _check_type 

23 

24GREEN_CIRCLE = "🟢" 

25YELLOW_CIRCLE = "🟡" 

26RED_CIRCLE = "🔴" 

27 

28EXIT_CODE_SUCCESS = 0 

29EXIT_CODE_ERROR = 1 

30EXIT_CODE_COMMAND_NOT_FOUND = 127 

31 

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

33 

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

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

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

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

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

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

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

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

42 

43 

44def print_json(data: dict[str, bool]) -> None: 

45 """ 

46 Take a dict of command: output and print it. 

47 """ 

48 print(json.dumps(data)) 

49 

50 

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

52 """ 

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

54 

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

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

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

58 

59 Args: 

60 tools: list of commands that ran 

61 results: list of return values from these commands 

62 """ 

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

64 

65 

66def with_exit_code() -> T_Outer_Wrapper: 

67 """ 

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

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

70 

71 Usage: 

72 > @app.command() 

73 > @with_exit_code() 

74 def some_command(): ... 

75 

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

77 """ 

78 

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

80 @functools.wraps(func) 

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

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

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

84 

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

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

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

88 # print {tool: success} 

89 # but only if a retcode is returned, 

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

91 dump_tools_with_results([func], [result]) 

92 

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

94 raise typer.Exit(code=retcode) 

95 

96 if retcode in _ignore_exit_codes: # pragma: no cover 

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

98 return EXIT_CODE_SUCCESS 

99 

100 return retcode 

101 

102 return inner_wrapper 

103 

104 return outer_wrapper 

105 

106 

107class Verbosity(enum.Enum): 

108 """ 

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

110 """ 

111 

112 # typer enum can only be string 

113 quiet = "1" 

114 normal = "2" 

115 verbose = "3" 

116 debug = "4" # only for internal use 

117 

118 @staticmethod 

119 def _compare( 

120 self: "Verbosity", 

121 other: "Verbosity_Comparable", 

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

123 ) -> bool: 

124 """ 

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

126 

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

128 

129 Args: 

130 self: the first Verbosity 

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

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

133 """ 

134 match other: 

135 case Verbosity(): 

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

137 case int(): 

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

139 case str(): 

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

141 

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

143 """ 

144 Magic method for self > other. 

145 """ 

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

147 

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

149 """ 

150 Method magic for self >= other. 

151 """ 

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

153 

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

155 """ 

156 Magic method for self < other. 

157 """ 

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

159 

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

161 """ 

162 Magic method for self <= other. 

163 """ 

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

165 

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

167 """ 

168 Magic method for self == other. 

169 

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

171 """ 

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

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

174 # special cases where Typer instanciates its cli arguments, 

175 # return False or it will crash 

176 return False 

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

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

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

180 

181 def __hash__(self) -> int: 

182 """ 

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

184 """ 

185 return hash(self.value) 

186 

187 

188Verbosity_Comparable = Verbosity | str | int 

189 

190DEFAULT_VERBOSITY = Verbosity.normal 

191 

192 

193class Format(enum.Enum): 

194 """ 

195 Options for su6 --format. 

196 """ 

197 

198 text = "text" 

199 json = "json" 

200 

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

202 """ 

203 Magic method for self == other. 

204 

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

206 """ 

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

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

209 # special cases where Typer instanciates its cli arguments, 

210 # return False or it will crash 

211 return False 

212 return self.value == other 

213 

214 def __hash__(self) -> int: 

215 """ 

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

217 """ 

218 return hash(self.value) 

219 

220 

221DEFAULT_FORMAT = Format.text 

222 

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

224 

225DEFAULT_BADGE = "coverage.svg" 

226 

227 

228@dataclass 

229class Config: 

230 """ 

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

232 

233 Also accessible via state.config 

234 """ 

235 

236 directory: str = "." 

237 pyproject: str = "pyproject.toml" 

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

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

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

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

242 

243 def __post_init__(self) -> None: 

244 """ 

245 Update the value of badge to the default path. 

246 """ 

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

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

249 self.badge = DEFAULT_BADGE 

250 

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

252 """ 

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

254 """ 

255 if self.include: 

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

257 elif self.exclude: 

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

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

260 return options 

261 

262 

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

264 

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

266 

267 

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

269 """ 

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

271 

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

273 """ 

274 try: 

275 _check_type(value, expected_type) 

276 return True 

277 except TypeCheckError: 

278 return False 

279 

280 

281@dataclass 

282class ConfigError(Exception): 

283 """ 

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

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

286 """ 

287 

288 key: str 

289 value: typing.Any 

290 expected_type: type 

291 

292 def __post_init__(self) -> None: 

293 """ 

294 Store the actual type of the config variable. 

295 """ 

296 self.actual_type = type(self.value) 

297 

298 def __str__(self) -> str: 

299 """ 

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

301 """ 

302 return ( 

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

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

305 ) 

306 

307 

308T = typing.TypeVar("T") 

309 

310 

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

312 """ 

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

314 

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

316 """ 

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

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

319 compare = data.get(key) 

320 if compare is None: 

321 # skip! 

322 continue 

323 if not check_type(compare, _type): 

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

325 

326 final[key] = compare 

327 return final 

328 

329 

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

331 """ 

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

333 

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

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

336 

337 Args: 

338 overwrites: cli arguments can overwrite the config toml. 

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

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

341 """ 

342 if toml_path is None: 

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

344 

345 if not toml_path: 

346 return None 

347 

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

349 full_config = tomllib.load(f) 

350 

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

352 su6_config_dict |= overwrites 

353 

354 su6_config_dict["pyproject"] = toml_path 

355 su6_config_dict = _ensure_types(su6_config_dict, Config.__annotations__) 

356 

357 return Config(**su6_config_dict) 

358 

359 

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

361 """ 

362 Load the relevant pyproject.toml config settings. 

363 

364 Args: 

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

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

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

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

369 """ 

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

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

372 

373 try: 

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

375 return config 

376 raise ValueError("Falsey config?") 

377 except Exception as e: 

378 # something went wrong parsing config, use defaults 

379 if verbosity > 3: 

380 # verbosity = debug 

381 raise e 

382 elif verbosity > 2: 

383 # verbosity = verbose 

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

385 return Config(**overwrites) 

386 

387 

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

389 """ 

390 'print' but with blue text. 

391 """ 

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

393 

394 

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

396 """ 

397 'print' but with yellow text. 

398 """ 

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

400 

401 

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

403 """ 

404 'print' but with red text. 

405 """ 

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

407 

408 

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

410 """ 

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

412 """ 

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

414 

415 

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

417 """ 

418 Print stdout in yellow and stderr in red. 

419 """ 

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

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

422 warn(stdout) 

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

424 danger(stderr) 

425 

426 

427@dataclass() 

428class ApplicationState: 

429 """ 

430 Application State - global user defined variables. 

431 

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

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

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

435 """ 

436 

437 verbosity: Verbosity = DEFAULT_VERBOSITY 

438 output_format: Format = DEFAULT_FORMAT 

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

440 config: MaybeConfig = None 

441 

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

443 """ 

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

445 """ 

446 if "verbosity" in overwrites: 

447 self.verbosity = overwrites["verbosity"] 

448 if "config_file" in overwrites: 

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

450 if "output_format" in overwrites: 

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

452 

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

454 return self.config 

455 

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

457 """ 

458 Overwrite default/toml settings with cli values. 

459 

460 Example: 

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

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

463 """ 

464 existing_config = self.load_config() if self.config is None else self.config 

465 

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

467 # replace is dataclass' update function 

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

469 return self.config 

470 

471 

472state = ApplicationState()