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 json

8 import operator

9 import os

10 import sys

11 import tomllib

12 import types

13 import typing

14 from dataclasses import dataclass, replace

15

16 import black.files

17 import typer

18 from plumbum.machines import LocalCommand

19 from rich import print

20 from typeguard import TypeCheckError

21 from typeguard import check_type as _check_type

22

23 GREEN_CIRCLE = "🟢"

24 YELLOW_CIRCLE = "🟡"

25 RED_CIRCLE = "🔴"

26

27 EXIT_CODE_SUCCESS = 0

28 EXIT_CODE_ERROR = 1

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

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

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

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

39

40

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

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

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

179 Verbosity_Comparable = Verbosity | str | int

180

181 DEFAULT_VERBOSITY = Verbosity.normal

182

183

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

212 DEFAULT_FORMAT = Format.text

213

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

215

216

217@dataclass

218 class Config:

219 """

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

221

222 Also accessible via state.config

223 """

224

225 directory: str = "."

226 pyproject: str = "pyproject.toml"

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

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

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

230

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

232 """

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

234 """

235 if self.include:

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

237 elif self.exclude:

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

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

240 return options

241

242

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

244

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

246

247

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

249 """

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

251

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

253 """

254 try:

255 _check_type(value, expected_type)

256 return True

257 except TypeCheckError:

258 return False

259

260

261@dataclass

262 class ConfigError(Exception):

263 """

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

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

266 """

267

268 key: str

269 value: typing.Any

270 expected_type: type

271

272 def __post_init__(self) -> None:

273 """

274 Store the actual type of the config variable.

275 """

276 self.actual_type = type(self.value)

277

278 def __str__(self) -> str:

279 """

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

281 """

282 return (

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

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

285 )

286

287

288 T = typing.TypeVar("T")

289

290

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

292 """

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

294

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

296 """

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

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

299 compare = data.get(key)

300 if compare is None:

301 # skip!

302 continue

303 if not check_type(compare, _type):

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

305

306 final[key] = compare

307 return final

308

309

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

311 """

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

313

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

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

316

317 Args:

318 overwrites: cli arguments can overwrite the config toml.

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

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

321 """

322 if toml_path is None:

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

324

325 if not toml_path:

326 return None

327

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

329 full_config = tomllib.load(f)

330

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

332 su6_config_dict |= overwrites

333

334 su6_config_dict["pyproject"] = toml_path

335 su6_config_dict = _ensure_types(su6_config_dict, Config.__annotations__)

336

337 return Config(**su6_config_dict)

338

339

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

341 """

342 Load the relevant pyproject.toml config settings.

343

344 Args:

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

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

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

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

349 """

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

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

352

353 try:

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

355 return config

356 raise ValueError("Falsey config?")

357 except Exception as e:

358 # something went wrong parsing config, use defaults

359 if verbosity > 3:

360 # verbosity = debug

361 raise e

362 elif verbosity > 2:

363 # verbosity = verbose

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

365 return Config(**overwrites)

366

367

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

369 """

370 'print' but with blue text.

371 """

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

373

374

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

376 """

377 'print' but with yellow text.

378 """

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

380

381

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

383 """

384 'print' but with red text.

385 """

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

387

388

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

390 """

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

392 """

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

394

395

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

397 """

398 Print stdout in yellow and stderr in red.

399 """

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

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

402 warn(stdout)

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

404 danger(stderr)

405

406

407@dataclass()

408 class ApplicationState:

409 """

410 Application State - global user defined variables.

411

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

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

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

415 """

416

417 verbosity: Verbosity = DEFAULT_VERBOSITY

418 format: Format = DEFAULT_FORMAT

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

420 config: MaybeConfig = None

421

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

423 """

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

425 """

426 if "verbosity" in overwrites:

427 self.verbosity = overwrites["verbosity"]

428 if "config_file" in overwrites:

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

430 if "format" in overwrites:

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

432

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

434 return self.config

435

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

437 """

438 Overwrite default/toml settings with cli values.

439

440 Example:

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

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

443 """

444 if self.config is None:

445 # not loaded yet!

446 existing_config = self.load_config()

447 else:

448 existing_config = self.config

449

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

451 # replace is dataclass' update function

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

453 return self.config

454

455

456 state = ApplicationState()