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

1"""

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

3"""

4 import enum

5 import functools

6 import inspect

7 import operator

8 import os

9 import sys

10 import tomllib

11 import types

12 import typing

13 from dataclasses import dataclass, replace

14

15 import black.files

16 import typer

17 from plumbum.machines import LocalCommand

18 from rich import print

19 from typeguard import TypeCheckError

20 from typeguard import check_type as _check_type

21

22 GREEN_CIRCLE = "🟢"

23 YELLOW_CIRCLE = "🟡"

24 RED_CIRCLE = "🔴"

25

26 EXIT_CODE_SUCCESS = 0

27 EXIT_CODE_ERROR = 1

28 EXIT_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

32 T_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

34 T_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

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

38

39

40 def 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

73 class 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

154 Verbosity_Comparable = Verbosity | str | int

155

156 DEFAULT_VERBOSITY = Verbosity.normal

157

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

159

160

161@dataclass

162 class 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

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

188

189

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

191 """

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

193

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

195 """

196 try:

197 _check_type(value, expected_type)

198 return True

199 except TypeCheckError:

200 return False

201

202

203@dataclass

204 class ConfigError(Exception):

205 """

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

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

208 """

209

210 key: str

211 value: typing.Any

212 expected_type: type

213

214 def __post_init__(self) -> None:

215 """

216 Store the actual type of the config variable.

217 """

218 self.actual_type = type(self.value)

219

220 def __str__(self) -> str:

221 """

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

223 """

224 return (

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

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

227 )

228

229

230 T = typing.TypeVar("T")

231

232

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

234 """

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

236

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

238 """

239 final = {}

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

241 compare = data.get(key)

242 if compare is None:

243 # skip!

244 continue

245 if not check_type(compare, _type):

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

247

248 final[key] = compare

249 return final

250

251

252 def _get_su6_config(overwrites: dict[str, typing.Any], toml_path: str = None) -> typing.Optional[Config]:

253 """

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

255

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

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

258

259 Args:

260 overwrites: cli arguments can overwrite the config toml.

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

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

263 """

264 if toml_path is None:

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

266

267 if not toml_path:

268 return None

269

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

271 full_config = tomllib.load(f)

272

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

274 su6_config_dict |= overwrites

275

276 su6_config_dict["pyproject"] = toml_path

277 su6_config_dict = _ensure_types(su6_config_dict, Config.__annotations__)

278

279 return Config(**su6_config_dict)

280

281

282 def get_su6_config(

283 verbosity: Verbosity = DEFAULT_VERBOSITY, toml_path: str = None, **overwrites: typing.Any

284) -> typing.Optional[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

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

313 """

314 'print' but with blue text.

315 """

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

317

318

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

320 """

321 'print' but with yellow text.

322 """

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

324

325

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

327 """

328 'print' but with red text.

329 """

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

331

332

333 def 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

340 def 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()

352 class 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: str = None # will be filled with black's search logic

363 config: Config = 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 self.load_config()

388

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

390 # replace is dataclass' update function

391 self.config = replace(self.config, **values)

392 return self.config

393

394

395 state = ApplicationState()