Coverage for src/typedal/config.py: 100%

107 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-12-05 14:43 +0100

1""" 

2TypeDAL can be configured by a combination of pyproject.toml (static), env (dynamic) and code (programmic). 

3""" 

4import os 

5import typing 

6import warnings 

7from pathlib import Path 

8from typing import Any, Optional 

9 

10import black.files 

11import tomli 

12from configuraptor import TypedConfig, alias 

13from dotenv import dotenv_values, find_dotenv 

14 

15if typing.TYPE_CHECKING: # pragma: no cover 

16 from edwh_migrate import Config as MigrateConfig 

17 from pydal2sql.typer_support import Config as P2SConfig 

18 

19 

20class TypeDALConfig(TypedConfig): 

21 """ 

22 Unified config for TypeDAL runtime behavior and migration utilities. 

23 """ 

24 

25 # typedal: 

26 database: str 

27 dialect: str 

28 folder: str = "databases" 

29 caching: bool = True 

30 pool_size: int = 0 

31 pyproject: str 

32 connection: str = "default" 

33 

34 # pydal2sql: 

35 input: str = "" # noqa: A003 

36 output: str = "" 

37 noop: bool = False 

38 magic: bool = True 

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

40 function: str = "define_tables" 

41 

42 # edwh-migrate: 

43 # migrate uri = database 

44 database_to_restore: Optional[str] 

45 migrate_cat_command: Optional[str] 

46 schema_version: Optional[str] 

47 redis_host: Optional[str] 

48 migrate_table: str = "typedal_implemented_features" 

49 flag_location: str 

50 create_flag_location: bool = True 

51 schema: str = "public" 

52 

53 # typedal (depends on properties above) 

54 migrate: bool = True 

55 fake_migrate: bool = False 

56 

57 # aliases: 

58 db_uri: str = alias("database") 

59 db_type: str = alias("dialect") 

60 db_folder: str = alias("folder") 

61 

62 def __repr__(self) -> str: 

63 """ 

64 Dump the config to a (fancy) string. 

65 """ 

66 return f"<TypeDAL {self.__dict__}>" 

67 

68 def to_pydal2sql(self) -> "P2SConfig": 

69 """ 

70 Convert the config to the format required by pydal2sql. 

71 """ 

72 from pydal2sql.typer_support import Config, get_pydal2sql_config 

73 

74 if self.pyproject: # pragma: no cover 

75 project = Path(self.pyproject).read_text() 

76 

77 if "[tool.typedal]" not in project and "[tool.pydal2sql]" in project: 

78 # no typedal config, but existing p2s config: 

79 return get_pydal2sql_config(self.pyproject) 

80 

81 return Config.load( 

82 { 

83 "db_type": self.dialect, 

84 "format": "edwh-migrate", 

85 "tables": self.tables, 

86 "magic": self.magic, 

87 "function": self.function, 

88 "input": self.input, 

89 "output": self.output, 

90 "pyproject": self.pyproject, 

91 } 

92 ) 

93 

94 def to_migrate(self) -> "MigrateConfig": 

95 """ 

96 Convert the config to the format required by edwh-migrate. 

97 """ 

98 from edwh_migrate import Config, get_config 

99 

100 if self.pyproject: # pragma: no cover 

101 project = Path(self.pyproject).read_text() 

102 

103 if "[tool.typedal]" not in project and "[tool.migrate]" in project: 

104 # no typedal config, but existing p2s config: 

105 return get_config() 

106 

107 return Config.load( 

108 { 

109 "migrate_uri": self.database, 

110 "schema_version": self.schema_version, 

111 "redis_host": self.redis_host, 

112 "migrate_cat_command": self.migrate_cat_command, 

113 "database_to_restore": self.database_to_restore, 

114 "migrate_table": self.migrate_table, 

115 "flag_location": self.flag_location, 

116 "create_flag_location": self.create_flag_location, 

117 "schema": self.schema, 

118 "db_folder": self.folder, 

119 "migrations_file": self.output, 

120 } 

121 ) 

122 

123 

124def find_pyproject_toml(directory: str | None = None) -> typing.Optional[str]: 

125 """ 

126 Find the project's config toml, looks up until it finds the project root (black's logic). 

127 """ 

128 return black.files.find_pyproject_toml((directory or os.getcwd(),)) 

129 

130 

131def _load_toml(path: str | bool | None = True) -> tuple[str, dict[str, Any]]: 

132 """ 

133 Path can be a file, a directory, a bool or None. 

134 

135 If it is True or None, the default logic is used. 

136 If it is False, no data is loaded. 

137 if it is a directory, the pyproject.toml will be searched there. 

138 If it is a path, that file will be used. 

139 """ 

140 if path is False: 

141 toml_path = None 

142 elif path in (True, None): 

143 toml_path = find_pyproject_toml() 

144 elif Path(str(path)).is_file(): 

145 toml_path = str(path) 

146 else: 

147 toml_path = find_pyproject_toml(str(path)) 

148 

149 if not toml_path: 

150 # nothing to load 

151 return "", {} 

152 

153 try: 

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

155 data = tomli.load(f) 

156 

157 return toml_path or "", typing.cast(dict[str, Any], data["tool"]["typedal"]) 

158 except Exception as e: 

159 warnings.warn(f"Could not load typedal config toml: {e}", source=e) 

160 return toml_path or "", {} 

161 

162 

163def _load_dotenv(path: str | bool | None = True) -> tuple[str, dict[str, Any]]: 

164 if path is False: 

165 dotenv_path = None 

166 elif path in (True, None): 

167 dotenv_path = find_dotenv(usecwd=True) 

168 elif Path(str(path)).is_file(): 

169 dotenv_path = str(path) 

170 else: 

171 dotenv_path = str(Path(str(path)) / ".env") 

172 

173 if not dotenv_path: 

174 return "", {} 

175 

176 # 1. find everything with TYPEDAL_ prefix 

177 # 2. remove that prefix 

178 # 3. format values if possible 

179 data = dotenv_values(dotenv_path) 

180 data |= os.environ # higher prio than .env 

181 

182 typedal_data = {k.lower().removeprefix("typedal_"): v for k, v in data.items()} 

183 

184 return dotenv_path, typedal_data 

185 

186 

187DB_ALIASES = { 

188 "postgresql": "postgres", 

189 "psql": "postgres", 

190 "sqlite3": "sqlite", 

191} 

192 

193 

194def get_db_for_alias(db_name: str) -> str: 

195 """ 

196 Convert a db dialect alias to the standard name. 

197 """ 

198 return DB_ALIASES.get(db_name, db_name) 

199 

200 

201DEFAULTS: dict[str, Any | typing.Callable[[dict[str, Any]], Any]] = { 

202 "database": lambda data: data.get("db_uri") or "sqlite:memory", 

203 "dialect": lambda data: get_db_for_alias(data["database"].split(":")[0]) 

204 if ":" in data["database"] 

205 else data.get("db_type"), 

206 "migrate": lambda data: not (data.get("input") or data.get("output")), 

207 "folder": lambda data: data.get("db_folder"), 

208 "flag_location": lambda data: f"{db_folder}/flags" 

209 if (db_folder := (data.get("folder") or data.get("db_folder"))) 

210 else "/flags", 

211 "pool_size": lambda data: 1 if data.get("dialect", "sqlite") == "sqlite" else 3, 

212} 

213 

214 

215def fill_defaults(data: dict[str, Any], prop: str) -> None: 

216 """ 

217 Fill missing property defaults with (calculated) sane defaults. 

218 """ 

219 if data.get(prop, None) is None: 

220 default = DEFAULTS.get(prop) 

221 if callable(default): 

222 default = default(data) 

223 data[prop] = default 

224 

225 

226TRANSFORMS: dict[str, typing.Callable[[dict[str, Any]], Any]] = { 

227 "database": lambda data: data["database"] 

228 if (":" in data["database"] or not data.get("dialect")) 

229 else (data["dialect"] + "://" + data["database"]) 

230} 

231 

232 

233def transform(data: dict[str, Any], prop: str) -> bool: 

234 """ 

235 After the user has chosen a value, possibly transform it. 

236 """ 

237 if fn := TRANSFORMS.get(prop): 

238 data[prop] = fn(data) 

239 return True 

240 return False 

241 

242 

243def load_config( 

244 _use_pyproject: bool | str | None = True, _use_env: bool | str | None = True, **fallback: Any 

245) -> TypeDALConfig: 

246 """ 

247 Combines multiple sources of config into one config instance. 

248 """ 

249 # load toml data 

250 # load .env data 

251 # combine and fill with fallback values 

252 # load typedal config or fail 

253 toml_path, toml = _load_toml(_use_pyproject) 

254 dotenv_path, dotenv = _load_dotenv(_use_env) 

255 

256 connection_name = dotenv.get("connection", "") or toml.get("default", "") 

257 connection: dict[str, Any] = (toml.get(connection_name) if connection_name else toml) or {} 

258 

259 combined = connection | dotenv | fallback 

260 combined = {k.replace("-", "_"): v for k, v in combined.items()} 

261 

262 combined["pyproject"] = toml_path 

263 combined["connection"] = connection_name 

264 

265 for prop in TypeDALConfig.__annotations__: 

266 fill_defaults(combined, prop) 

267 

268 for prop in TypeDALConfig.__annotations__: 

269 transform(combined, prop) 

270 

271 return TypeDALConfig.load(combined, convert_types=True)