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

103 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-12-04 19:01 +0100

1""" 

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

3""" 

4import os 

5import typing 

6from pathlib import Path 

7from typing import Any, Optional 

8 

9import black.files 

10import tomli 

11from configuraptor import TypedConfig, alias 

12from dotenv import dotenv_values, find_dotenv 

13 

14if typing.TYPE_CHECKING: # pragma: no cover 

15 from edwh_migrate import Config as MigrateConfig 

16 from pydal2sql.typer_support import Config as P2SConfig 

17 

18 

19class TypeDALConfig(TypedConfig): 

20 """ 

21 Unified config for TypeDAL runtime behavior and migration utilities. 

22 """ 

23 

24 # typedal: 

25 database: str 

26 dialect: str 

27 folder: str = "databases" 

28 caching: bool = True 

29 pool_size: int = 0 

30 pyproject: str 

31 

32 # pydal2sql: 

33 input: str = "" # noqa: A003 

34 output: str = "" 

35 noop: bool = False 

36 magic: bool = True 

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

38 function: str = "define_tables" 

39 

40 # edwh-migrate: 

41 # migrate uri = database 

42 database_to_restore: Optional[str] 

43 migrate_cat_command: Optional[str] 

44 schema_version: Optional[str] 

45 redis_host: Optional[str] 

46 migrate_table: str = "typedal_implemented_features" 

47 flag_location: str 

48 create_flag_location: bool = True 

49 schema: str = "public" 

50 

51 # typedal (depends on properties above) 

52 migrate: bool = True 

53 fake_migrate: bool = False 

54 

55 # aliases: 

56 db_uri: str = alias("database") 

57 db_type: str = alias("dialect") 

58 db_folder: str = alias("folder") 

59 

60 def __repr__(self) -> str: 

61 """ 

62 Dump the config to a (fancy) string. 

63 """ 

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

65 

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

67 """ 

68 Convert the config to the format required by pydal2sql. 

69 """ 

70 from pydal2sql.typer_support import Config, get_pydal2sql_config 

71 

72 if self.pyproject: # pragma: no cover 

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

74 

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

76 # no typedal config, but existing p2s config: 

77 return get_pydal2sql_config(self.pyproject) 

78 

79 return Config.load( 

80 { 

81 "db_type": self.dialect, 

82 "format": "edwh-migrate", 

83 "tables": self.tables, 

84 "magic": self.magic, 

85 "function": self.function, 

86 "input": self.input, 

87 "output": self.output, 

88 "pyproject": self.pyproject, 

89 } 

90 ) 

91 

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

93 """ 

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

95 """ 

96 from edwh_migrate import Config, get_config 

97 

98 if self.pyproject: # pragma: no cover 

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

100 

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

102 # no typedal config, but existing p2s config: 

103 return get_config() 

104 

105 return Config.load( 

106 { 

107 "migrate_uri": self.database, 

108 "schema_version": self.schema_version, 

109 "redis_host": self.redis_host, 

110 "migrate_cat_command": self.migrate_cat_command, 

111 "database_to_restore": self.database_to_restore, 

112 "migrate_table": self.migrate_table, 

113 "flag_location": self.flag_location, 

114 "create_flag_location": self.create_flag_location, 

115 "schema": self.schema, 

116 "db_folder": self.folder, 

117 "migrations_file": self.output, 

118 } 

119 ) 

120 

121 

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

123 """ 

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

125 """ 

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

127 

128 

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

130 """ 

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

132 

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

134 If it is False, no data is loaded. 

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

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

137 """ 

138 if path is False: 

139 toml_path = None 

140 elif path in (True, None): 

141 toml_path = find_pyproject_toml() 

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

143 toml_path = str(path) 

144 else: 

145 toml_path = find_pyproject_toml(str(path)) 

146 

147 if not toml_path: 

148 # nothing to load 

149 return "", {} 

150 

151 try: 

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

153 data = tomli.load(f) 

154 

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

156 except Exception: 

157 return toml_path or "", {} 

158 

159 

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

161 if path is False: 

162 dotenv_path = None 

163 elif path in (True, None): 

164 dotenv_path = find_dotenv(usecwd=True) 

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

166 dotenv_path = str(path) 

167 else: 

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

169 

170 if not dotenv_path: 

171 return "", {} 

172 

173 # 1. find everything with TYPEDAL_ prefix 

174 # 2. remove that prefix 

175 # 3. format values if possible 

176 data = dotenv_values(dotenv_path) 

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

178 

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

180 

181 return dotenv_path, typedal_data 

182 

183 

184DB_ALIASES = { 

185 "postgresql": "postgres", 

186 "psql": "postgres", 

187 "sqlite3": "sqlite", 

188} 

189 

190 

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

192 """ 

193 Convert a db dialect alias to the standard name. 

194 """ 

195 return DB_ALIASES.get(db_name, db_name) 

196 

197 

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

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

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

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

202 else data.get("db_type"), 

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

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

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

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

207 else "/flags", 

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

209} 

210 

211 

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

213 """ 

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

215 """ 

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

217 default = DEFAULTS.get(prop) 

218 if callable(default): 

219 default = default(data) 

220 data[prop] = default 

221 

222 

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

224 "database": lambda data: data["database"] 

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

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

227} 

228 

229 

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

231 """ 

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

233 """ 

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

235 data[prop] = fn(data) 

236 return True 

237 return False 

238 

239 

240def load_config( 

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

242) -> TypeDALConfig: 

243 """ 

244 Combines multiple sources of config into one config instance. 

245 """ 

246 # load toml data 

247 # load .env data 

248 # combine and fill with fallback values 

249 # load typedal config or fail 

250 toml_path, toml = _load_toml(_use_pyproject) 

251 dotenv_path, dotenv = _load_dotenv(_use_env) 

252 

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

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

255 

256 combined = connection | dotenv | fallback 

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

258 

259 combined["pyproject"] = toml_path 

260 

261 for prop in TypeDALConfig.__annotations__: 

262 fill_defaults(combined, prop) 

263 

264 for prop in TypeDALConfig.__annotations__: 

265 transform(combined, prop) 

266 

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