Coverage for src/pydal2sql_core/state.py: 59%

128 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2026-04-22 11:54 +0200

1import inspect 

2import operator 

3import sys 

4import typing 

5from dataclasses import dataclass 

6from enum import Enum, EnumMeta 

7from pathlib import Path 

8from typing import Any, Optional 

9 

10import configuraptor 

11import tomli 

12from configuraptor import alias 

13from configuraptor.helpers import find_pyproject_toml 

14 

15from .types import ( 

16 DEFAULT_OUTPUT_FORMAT, 

17 SUPPORTED_DATABASE_TYPES_WITH_ALIASES, 

18 SUPPORTED_OUTPUT_FORMATS, 

19) 

20 

21 

22class ReprEnumMeta(EnumMeta): 

23 """ 

24 Give an Enum class a fancy repr. 

25 """ 

26 

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

28 """ 

29 Print all of the enum's members. 

30 """ 

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

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

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

34 

35 

36class DynamicEnum(Enum, metaclass=ReprEnumMeta): 

37 """ 

38 Combine the enum class with the ReprEnumMeta metaclass. 

39 """ 

40 

41 

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

43 """ 

44 Transform a typing.Literal statement into an Enum. 

45 

46 literal_type can be a typing.Literal or a Union of Literals 

47 """ 

48 literals: list[str] = [] 

49 

50 if hasattr(literal_type, "__args__"): 

51 for arg in typing.get_args(literal_type): 

52 if hasattr(arg, "__args__"): 

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

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

55 else: 

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

57 literals.append(arg) 

58 else: 

59 # e.g. literal_type = 'one' 

60 literals.append(str(literal_type)) 

61 

62 literals.sort() 

63 

64 enum_dict = {} 

65 

66 for literal in literals: 

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

68 enum_value = literal 

69 enum_dict[enum_name] = enum_value 

70 

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

72 

73 

74class Verbosity(Enum): 

75 """ 

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

77 """ 

78 

79 # typer enum can only be string 

80 quiet = "1" 

81 normal = "2" 

82 verbose = "3" 

83 debug = "4" # only for internal use 

84 

85 @staticmethod 

86 def _compare( 

87 self: "Verbosity", 

88 other: "Verbosity_Comparable", 

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

90 ) -> bool: 

91 """ 

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

93 

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

95 

96 Args: 

97 self: the first Verbosity 

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

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

100 """ 

101 match other: 

102 case Verbosity(): 

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

104 case int(): 

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

106 case str(): 

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

108 

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

110 """ 

111 Magic method for self > other. 

112 """ 

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

114 

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

116 """ 

117 Method magic for self >= other. 

118 """ 

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

120 

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

122 """ 

123 Magic method for self < other. 

124 """ 

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

126 

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

128 """ 

129 Magic method for self <= other. 

130 """ 

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

132 

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

134 """ 

135 Magic method for self == other. 

136 

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

138 """ 

139 if other is None: 

140 other = DEFAULT_VERBOSITY 

141 

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

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

144 # special cases where Typer instanciates its cli arguments, 

145 # return False or it will crash 

146 return False 

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

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

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

150 

151 def __hash__(self) -> int: 

152 """ 

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

154 """ 

155 return hash(self.value) 

156 

157 

158Verbosity_Comparable = Verbosity | str | int 

159 

160DEFAULT_VERBOSITY = Verbosity.normal 

161 

162 

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

164 """ 

165 Used by state.config and plugin configs. 

166 """ 

167 

168 _strict = True 

169 

170 

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

172 

173 

174# @dataclass 

175class Config(AbstractConfig): 

176 """ 

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

178 

179 Also accessible via state.config 

180 """ 

181 

182 # settings go here 

183 db_type: typing.Optional[SUPPORTED_DATABASE_TYPES_WITH_ALIASES] = None 

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

185 magic: bool = False 

186 function: str = "define_tables" 

187 format: SUPPORTED_OUTPUT_FORMATS = DEFAULT_OUTPUT_FORMAT 

188 dialect: typing.Optional[SUPPORTED_DATABASE_TYPES_WITH_ALIASES] = alias("db_type") 

189 input: Optional[str] = None 

190 output: Optional[str] = None 

191 noop: bool = False 

192 

193 pyproject: typing.Optional[str] = None 

194 

195 

196MaybeConfig = Optional[Config] 

197 

198 

199def _get_pydal2sql_config(overwrites: dict[str, Any], toml_path: Optional[str | Path] = None) -> MaybeConfig: 

200 """ 

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

202 

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

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

205 

206 Args: 

207 overwrites: cli arguments can overwrite the config toml. 

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

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

210 """ 

211 if toml_path is None: 

212 toml_path = find_pyproject_toml() 

213 

214 if not toml_path: 

215 return None 

216 

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

218 full_config = tomli.load(f) 

219 

220 tool_config = full_config["tool"] 

221 

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

223 

224 config.update(pyproject=str(toml_path)) 

225 config.update(**overwrites) 

226 

227 return config 

228 

229 

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

231 """ 

232 Load the relevant pyproject.toml config settings. 

233 

234 Args: 

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

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

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

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

239 """ 

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

241 overwrites = configuraptor.convert_config(overwrites) 

242 

243 try: 

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

245 return config 

246 raise ValueError("Falsey config?") 

247 except Exception as e: 

248 # something went wrong parsing config, use defaults 

249 if verbosity > 3: 

250 # verbosity = debug 

251 raise e 

252 elif verbosity > 2: 

253 # verbosity = verbose 

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

255 return Config(**overwrites) 

256 

257 

258@dataclass() 

259class ApplicationState: 

260 """ 

261 Application State - global user defined variables. 

262 

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

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

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

266 

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

268 """ 

269 

270 verbosity: Verbosity = DEFAULT_VERBOSITY 

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

272 config: MaybeConfig = None 

273 

274 def __post_init__(self) -> None: 

275 """ 

276 Runs after the dataclass init. 

277 """ 

278 

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

280 """ 

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

282 

283 Also updates attached plugin configs. 

284 """ 

285 if "verbosity" in overwrites: 

286 self.verbosity = overwrites["verbosity"] 

287 if "config_file" in overwrites: 

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

289 

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

291 return self.config 

292 

293 def get_config(self) -> Config: 

294 """ 

295 Get a filled config instance. 

296 """ 

297 return self.config or self.load_config() 

298 

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

300 """ 

301 Overwrite default/toml settings with cli values. 

302 

303 Example: 

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

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

306 """ 

307 existing_config = self.get_config() 

308 

309 values = configuraptor.convert_config(values) 

310 existing_config.update(**values) 

311 return existing_config 

312 

313 

314state = ApplicationState()