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, field, replace

15

16 import black.files

17 import plumbum.commands.processes as pb

18 import typer

19 from plumbum.machines import LocalCommand

20 from rich import print

21 from typeguard import TypeCheckError

22 from typeguard import check_type as _check_type

23

24 GREEN_CIRCLE = "🟢"

25 YELLOW_CIRCLE = "🟡"

26 RED_CIRCLE = "🔴"

27

28 EXIT_CODE_SUCCESS = 0

29 EXIT_CODE_ERROR = 1

30 EXIT_CODE_COMMAND_NOT_FOUND = 127

31

32 PlumbumError = (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

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

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

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

42

43

44 def print_json(data: dict[str, typing.Any]) -> None:

45 """

46 Take a dict of {command: output} or the State and print it.

47 """

48 print(json.dumps(data, default=str))

49

50

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

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

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

188 Verbosity_Comparable = Verbosity | str | int

189

190 DEFAULT_VERBOSITY = Verbosity.normal

191

192

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

221 DEFAULT_FORMAT = Format.text

222

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

224

225 DEFAULT_BADGE = "coverage.svg"

226

227

228@dataclass

229 class 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: list[str] = field(default_factory=list)

239 exclude: list[str] = field(default_factory=list)

240 stop_after_first_failure: bool = False

241

242 ### pytest ###

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

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

245

246 def __post_init__(self) -> None:

247 """

248 Update the value of badge to the default path.

249 """

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

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

252 self.badge = DEFAULT_BADGE

253

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

255 """

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

257 """

258 if self.include:

259 tools = [_ for _ in options if _.__name__ in self.include]

260 tools.sort(key=lambda f: self.include.index(f.__name__))

261 return tools

262 elif self.exclude:

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

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

265 return options

266

267

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

269

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

271

272

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

274 """

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

276

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

278 """

279 try:

280 _check_type(value, expected_type)

281 return True

282 except TypeCheckError:

283 return False

284

285

286@dataclass

287 class ConfigError(Exception):

288 """

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

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

291 """

292

293 key: str

294 value: typing.Any

295 expected_type: type

296

297 def __post_init__(self) -> None:

298 """

299 Store the actual type of the config variable.

300 """

301 self.actual_type = type(self.value)

302

303 def __str__(self) -> str:

304 """

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

306 """

307 return (

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

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

310 )

311

312

313 T = typing.TypeVar("T")

314

315

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

317 """

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

319

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

321 """

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

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

324 compare = data.get(key)

325 if compare is None:

326 # skip!

327 continue

328 if not check_type(compare, _type):

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

330

331 final[key] = compare

332 return final

333

334

335 def _convert_config(items: dict[str, T]) -> dict[str, T]:

336 """

337 Converts the config dict (from toml) or 'overwrites' dict in two ways.

338

339 1. removes any items where the value is None, since in that case the default should be used;

340 2. replaces '-' in keys with '_' so it can be mapped to the Config properties.

341 """

342 return {k.replace("-", "_"): v for k, v in items.items() if v is not None}

343

344

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

346 """

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

348

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

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

351

352 Args:

353 overwrites: cli arguments can overwrite the config toml.

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

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

356 """

357 if toml_path is None:

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

359

360 if not toml_path:

361 return None

362

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

364 full_config = tomllib.load(f)

365

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

367 su6_config_dict |= overwrites

368

369 su6_config_dict["pyproject"] = toml_path

370 # first convert the keys, then ensure types. Otherwise, non-matching keys may be removed!

371 su6_config_dict = _convert_config(su6_config_dict)

372 su6_config_dict = _ensure_types(su6_config_dict, Config.__annotations__)

373

374 return Config(**su6_config_dict)

375

376

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

378 """

379 Load the relevant pyproject.toml config settings.

380

381 Args:

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

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

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

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

386 """

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

388 overwrites = _convert_config(overwrites)

389

390 try:

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

392 return config

393 raise ValueError("Falsey config?")

394 except Exception as e:

395 # something went wrong parsing config, use defaults

396 if verbosity > 3:

397 # verbosity = debug

398 raise e

399 elif verbosity > 2:

400 # verbosity = verbose

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

402 return Config(**overwrites)

403

404

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

406 """

407 'print' but with blue text.

408 """

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

410

411

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

413 """

414 'print' but with yellow text.

415 """

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

417

418

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

420 """

421 'print' but with red text.

422 """

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

424

425

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

427 """

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

429 """

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

431

432

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

434 """

435 Print stdout in yellow and stderr in red.

436 """

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

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

439 warn(stdout)

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

441 danger(stderr)

442

443

444@dataclass()

445 class ApplicationState:

446 """

447 Application State - global user defined variables.

448

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

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

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

452

453 To summarize: 'state' is applicable to all commands and config only to specific ones.

454 """

455

456 verbosity: Verbosity = DEFAULT_VERBOSITY

457 output_format: Format = DEFAULT_FORMAT

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

459 config: MaybeConfig = None

460

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

462 """

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

464 """

465 if "verbosity" in overwrites:

466 self.verbosity = overwrites["verbosity"]

467 if "config_file" in overwrites:

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

469 if "output_format" in overwrites:

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

471

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

473 return self.config

474

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

476 """

477 Overwrite default/toml settings with cli values.

478

479 Example:

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

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

482 """

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

484

485 values = _convert_config(values)

486 # replace is dataclass' update function

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

488 return self.config

489

490

491 state = ApplicationState()