Coverage for src/pydal2sql/typer_support.py: 100%

153 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-11-13 13:33 +0100

1""" 

2Cli-specific support 

3""" 

4import contextlib 

5import functools 

6import inspect 

7import operator 

8import os 

9import sys 

10import typing 

11from dataclasses import dataclass 

12from enum import Enum, EnumMeta 

13from pathlib import Path 

14from typing import Any, Optional 

15 

16import configuraptor 

17import dotenv 

18import rich 

19import tomli 

20import typer 

21from black.files import find_project_root 

22from configuraptor.helpers import find_pyproject_toml 

23from pydal2sql_core.types import SUPPORTED_DATABASE_TYPES_WITH_ALIASES 

24from su6.core import ( 

25 EXIT_CODE_ERROR, 

26 EXIT_CODE_SUCCESS, 

27 T_Command, 

28 T_Inner_Wrapper, 

29 T_Outer_Wrapper, 

30) 

31from typing_extensions import Never 

32 

33T_Literal = typing._SpecialForm 

34 

35LiteralType = typing.TypeVar("LiteralType", str, typing.Union[str, str] | T_Literal) 

36 

37 

38class ReprEnumMeta(EnumMeta): 

39 def __repr__(cls) -> str: # sourcery skip 

40 options = typing.cast(typing.Iterable[Enum], cls.__members__.values()) # for mypy 

41 members_repr = ", ".join(f"{m.value!r}" for m in options) 

42 return f"{cls.__name__}({members_repr})" 

43 

44 

45class DynamicEnum(Enum, metaclass=ReprEnumMeta): 

46 ... 

47 

48 

49def create_enum_from_literal(name: str, literal_type: LiteralType) -> typing.Type[DynamicEnum]: 

50 literals: list[str] = [] 

51 

52 if hasattr(literal_type, "__args__"): 

53 for arg in typing.get_args(literal_type): 

54 if hasattr(arg, "__args__"): 

55 # e.g. literal_type = typing.Union[typing.Literal['one', 'two']] 

56 literals.extend(typing.get_args(arg)) 

57 else: 

58 # e.g. literal_type = typing.Literal['one', 'two'] 

59 literals.append(arg) 

60 else: 

61 # e.g. literal_type = 'one' 

62 literals.append(str(literal_type)) 

63 

64 literals.sort() 

65 

66 enum_dict = {} 

67 

68 for literal in literals: 

69 enum_name = literal.replace(" ", "_").upper() 

70 enum_value = literal 

71 enum_dict[enum_name] = enum_value 

72 

73 return DynamicEnum(name, enum_dict) # type: ignore 

74 

75 

76class Verbosity(Enum): 

77 """ 

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

79 """ 

80 

81 # typer enum can only be string 

82 quiet = "1" 

83 normal = "2" 

84 verbose = "3" 

85 debug = "4" # only for internal use 

86 

87 @staticmethod 

88 def _compare( 

89 self: "Verbosity", 

90 other: "Verbosity_Comparable", 

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

92 ) -> bool: 

93 """ 

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

95 

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

97 

98 Args: 

99 self: the first Verbosity 

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

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

102 """ 

103 match other: 

104 case Verbosity(): 

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

106 case int(): 

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

108 case str(): 

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

110 

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

112 """ 

113 Magic method for self > other. 

114 """ 

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

116 

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

118 """ 

119 Method magic for self >= other. 

120 """ 

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

122 

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

124 """ 

125 Magic method for self < other. 

126 """ 

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

128 

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

130 """ 

131 Magic method for self <= other. 

132 """ 

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

134 

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

136 """ 

137 Magic method for self == other. 

138 

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

140 """ 

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

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

143 # special cases where Typer instanciates its cli arguments, 

144 # return False or it will crash 

145 return False 

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

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

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

149 

150 def __hash__(self) -> int: 

151 """ 

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

153 """ 

154 return hash(self.value) 

155 

156 

157Verbosity_Comparable = Verbosity | str | int 

158 

159DEFAULT_VERBOSITY = Verbosity.normal 

160 

161 

162class AbstractConfig(configuraptor.TypedConfig, configuraptor.Singleton): 

163 """ 

164 Used by state.config and plugin configs. 

165 """ 

166 

167 _strict = True 

168 

169 

170DB_Types: typing.Any = create_enum_from_literal("DBType", SUPPORTED_DATABASE_TYPES_WITH_ALIASES) 

171 

172 

173@dataclass 

174class Config(AbstractConfig): 

175 """ 

176 Used as typed version of the [tool.pydal2sql] part of pyproject.toml. 

177 

178 Also accessible via state.config 

179 """ 

180 

181 # settings go here 

182 db_type: typing.Optional[SUPPORTED_DATABASE_TYPES_WITH_ALIASES] = None 

183 magic: bool = False 

184 noop: bool = False 

185 tables: Optional[list[str]] = None 

186 pyproject: typing.Optional[str] = None 

187 function: str = "define_tables" 

188 

189 

190MaybeConfig = Optional[Config] 

191 

192 

193def _get_pydal2sql_config(overwrites: dict[str, Any], toml_path: str = None) -> MaybeConfig: 

194 """ 

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

196 

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

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

199 

200 Args: 

201 overwrites: cli arguments can overwrite the config toml. 

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

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

204 """ 

205 if toml_path is None: 

206 toml_path = find_pyproject_toml() 

207 

208 if not toml_path: 

209 return None 

210 

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

212 full_config = tomli.load(f) 

213 

214 tool_config = full_config["tool"] 

215 

216 config = configuraptor.load_into(Config, tool_config, key="pydal2sql") 

217 

218 config.update(pyproject=toml_path) 

219 config.update(**overwrites) 

220 

221 return config 

222 

223 

224def get_pydal2sql_config(toml_path: str = None, verbosity: Verbosity = DEFAULT_VERBOSITY, **overwrites: Any) -> Config: 

225 """ 

226 Load the relevant pyproject.toml config settings. 

227 

228 Args: 

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

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

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

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

233 """ 

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

235 overwrites = configuraptor.convert_config(overwrites) 

236 

237 try: 

238 if config := _get_pydal2sql_config(overwrites, toml_path=toml_path): 

239 return config 

240 raise ValueError("Falsey config?") 

241 except Exception as e: 

242 # something went wrong parsing config, use defaults 

243 if verbosity > 3: 

244 # verbosity = debug 

245 raise e 

246 elif verbosity > 2: 

247 # verbosity = verbose 

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

249 return Config(**overwrites) 

250 

251 

252@dataclass() 

253class ApplicationState: 

254 """ 

255 Application State - global user defined variables. 

256 

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

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

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

260 

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

262 """ 

263 

264 verbosity: Verbosity = DEFAULT_VERBOSITY 

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

266 config: MaybeConfig = None 

267 

268 def __post_init__(self) -> None: 

269 ... 

270 

271 def load_config(self, **overwrites: Any) -> Config: 

272 """ 

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

274 

275 Also updates attached plugin configs. 

276 """ 

277 if "verbosity" in overwrites: 

278 self.verbosity = overwrites["verbosity"] 

279 if "config_file" in overwrites: 

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

281 

282 self.config = get_pydal2sql_config(toml_path=self.config_file, **overwrites) 

283 return self.config 

284 

285 def get_config(self) -> Config: 

286 """ 

287 Get a filled config instance. 

288 """ 

289 return self.config or self.load_config() 

290 

291 def update_config(self, **values: Any) -> Config: 

292 """ 

293 Overwrite default/toml settings with cli values. 

294 

295 Example: 

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

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

298 """ 

299 existing_config = self.get_config() 

300 

301 values = configuraptor.convert_config(values) 

302 existing_config.update(**values) 

303 return existing_config 

304 

305 

306def with_exit_code(hide_tb: bool = True) -> T_Outer_Wrapper: 

307 """ 

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

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

310 

311 Usage: 

312 > @app.command() 

313 > @with_exit_code() 

314 def some_command(): ... 

315 

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

317 

318 See also: 

319 github.com:trialandsuccess/su6-checker 

320 """ 

321 

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

323 @functools.wraps(func) 

324 def inner_wrapper(*args: Any, **kwargs: Any) -> Never: 

325 try: 

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

327 except Exception as e: 

328 result = EXIT_CODE_ERROR 

329 if hide_tb: 

330 rich.print(f"[red]{e}[/red]", file=sys.stderr) 

331 else: # pragma: no cover 

332 raise e 

333 

334 if isinstance(result, bool): 

335 if result in (None, True): 

336 # assume no issue then 

337 result = EXIT_CODE_SUCCESS 

338 elif result is False: 

339 result = EXIT_CODE_ERROR 

340 

341 raise typer.Exit(code=int(result or 0)) 

342 

343 return inner_wrapper 

344 

345 return outer_wrapper 

346 

347 

348def _is_debug() -> bool: # pragma: no cover 

349 folder, _ = find_project_root((os.getcwd(),)) 

350 if not folder: 

351 folder = Path(os.getcwd()) 

352 dotenv.load_dotenv(folder / ".env") 

353 

354 return os.getenv("IS_DEBUG") == "1" 

355 

356 

357def is_debug() -> bool: # pragma: no cover 

358 with contextlib.suppress(Exception): 

359 return _is_debug() 

360 return False 

361 

362 

363IS_DEBUG = is_debug()