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

165 statements  

« prev     ^ index     » next       coverage.py v7.2.6, created at 2023-05-30 12:33 +0200

1""" 

2This file contains internal helpers used by cli.py. 

3""" 

4import enum 

5import functools 

6import inspect 

7import operator 

8import os 

9import sys 

10import tomllib 

11import types 

12import typing 

13from dataclasses import dataclass, replace 

14 

15import black.files 

16import typer 

17from plumbum.machines import LocalCommand 

18from rich import print 

19from typeguard import TypeCheckError 

20from typeguard import check_type as _check_type 

21 

22GREEN_CIRCLE = "🟢" 

23YELLOW_CIRCLE = "🟡" 

24RED_CIRCLE = "🔴" 

25 

26EXIT_CODE_SUCCESS = 0 

27EXIT_CODE_ERROR = 1 

28EXIT_CODE_COMMAND_NOT_FOUND = 127 

29 

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

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

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

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

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

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

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

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

38 

39 

40def with_exit_code() -> T_Outer_Wrapper: 

41 """ 

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

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

44 

45 Usage: 

46 > @app.command() 

47 > @with_exit_code() 

48 def some_command(): ... 

49 

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

51 """ 

52 

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

54 @functools.wraps(func) 

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

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

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

58 

59 if (retcode := int(func(*args, **kwargs))) and not _suppress: 

60 raise typer.Exit(code=retcode) 

61 

62 if retcode in _ignore_exit_codes: # pragma: no cover 

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

64 return EXIT_CODE_SUCCESS 

65 

66 return retcode 

67 

68 return inner_wrapper 

69 

70 return outer_wrapper 

71 

72 

73class Verbosity(enum.Enum): 

74 """ 

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

76 """ 

77 

78 # typer enum can only be string 

79 quiet = "1" 

80 normal = "2" 

81 verbose = "3" 

82 debug = "4" # only for internal use 

83 

84 @staticmethod 

85 def _compare( 

86 self: "Verbosity", 

87 other: "Verbosity_Comparable", 

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

89 ) -> bool: 

90 """ 

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

92 

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

94 

95 Args: 

96 self: the first Verbosity 

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

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

99 """ 

100 match other: 

101 case Verbosity(): 

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

103 case int(): 

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

105 case str(): 

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

107 

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

109 """ 

110 Magic method for self > other. 

111 """ 

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

113 

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

115 """ 

116 Method magic for self >= other. 

117 """ 

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

119 

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

121 """ 

122 Magic method for self < other. 

123 """ 

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

125 

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

127 """ 

128 Magic method for self <= other. 

129 """ 

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

131 

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

133 """ 

134 Magic method for self == other. 

135 

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

137 """ 

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

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

140 # special cases where Typer instanciates its cli arguments, 

141 # return False or it will crash 

142 return False 

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

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

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

146 

147 def __hash__(self) -> int: 

148 """ 

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

150 """ 

151 return hash(self.value) 

152 

153 

154Verbosity_Comparable = Verbosity | str | int 

155 

156DEFAULT_VERBOSITY = Verbosity.normal 

157 

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

159 

160 

161@dataclass 

162class Config: 

163 """ 

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

165 

166 Also accessible via state.config 

167 """ 

168 

169 directory: str = "." 

170 pyproject: str = "pyproject.toml" 

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

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

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

174 

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

176 """ 

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

178 """ 

179 if self.include: 

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

181 elif self.exclude: 

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

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

184 return options 

185 

186 

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

188 

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

190 

191 

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

193 """ 

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

195 

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

197 """ 

198 try: 

199 _check_type(value, expected_type) 

200 return True 

201 except TypeCheckError: 

202 return False 

203 

204 

205@dataclass 

206class ConfigError(Exception): 

207 """ 

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

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

210 """ 

211 

212 key: str 

213 value: typing.Any 

214 expected_type: type 

215 

216 def __post_init__(self) -> None: 

217 """ 

218 Store the actual type of the config variable. 

219 """ 

220 self.actual_type = type(self.value) 

221 

222 def __str__(self) -> str: 

223 """ 

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

225 """ 

226 return ( 

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

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

229 ) 

230 

231 

232T = typing.TypeVar("T") 

233 

234 

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

236 """ 

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

238 

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

240 """ 

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

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

243 compare = data.get(key) 

244 if compare is None: 

245 # skip! 

246 continue 

247 if not check_type(compare, _type): 

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

249 

250 final[key] = compare 

251 return final 

252 

253 

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

255 """ 

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

257 

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

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

260 

261 Args: 

262 overwrites: cli arguments can overwrite the config toml. 

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

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

265 """ 

266 if toml_path is None: 

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

268 

269 if not toml_path: 

270 return None 

271 

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

273 full_config = tomllib.load(f) 

274 

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

276 su6_config_dict |= overwrites 

277 

278 su6_config_dict["pyproject"] = toml_path 

279 su6_config_dict = _ensure_types(su6_config_dict, Config.__annotations__) 

280 

281 return Config(**su6_config_dict) 

282 

283 

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

285 """ 

286 Load the relevant pyproject.toml config settings. 

287 

288 Args: 

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

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

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

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

293 """ 

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

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

296 

297 try: 

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

299 return config 

300 raise ValueError("Falsey config?") 

301 except Exception as e: 

302 # something went wrong parsing config, use defaults 

303 if verbosity > 3: 

304 # verbosity = debug 

305 raise e 

306 elif verbosity > 2: 

307 # verbosity = verbose 

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

309 return Config(**overwrites) 

310 

311 

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

313 """ 

314 'print' but with blue text. 

315 """ 

316 print(f"[blue]{' '.join(args)}[/blue]") 

317 

318 

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

320 """ 

321 'print' but with yellow text. 

322 """ 

323 print(f"[yellow]{' '.join(args)}[/yellow]") 

324 

325 

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

327 """ 

328 'print' but with red text. 

329 """ 

330 print(f"[red]{' '.join(args)}[/red]") 

331 

332 

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

334 """ 

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

336 """ 

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

338 

339 

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

341 """ 

342 Print stdout in yellow and stderr in red. 

343 """ 

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

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

346 warn(stdout) 

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

348 danger(stderr) 

349 

350 

351@dataclass() 

352class ApplicationState: 

353 """ 

354 Application State - global user defined variables. 

355 

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

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

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

359 """ 

360 

361 verbosity: Verbosity = DEFAULT_VERBOSITY 

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

363 config: MaybeConfig = None 

364 

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

366 """ 

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

368 """ 

369 if "verbosity" in overwrites: 

370 self.verbosity = overwrites["verbosity"] 

371 if "config_file" in overwrites: 

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

373 

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

375 return self.config 

376 

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

378 """ 

379 Overwrite default/toml settings with cli values. 

380 

381 Example: 

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

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

384 """ 

385 if self.config is None: 

386 # not loaded yet! 

387 existing_config = self.load_config() 

388 else: 

389 existing_config = self.config 

390 

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

392 # replace is dataclass' update function 

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

394 return self.config 

395 

396 

397state = ApplicationState()